diff --git a/.devcontainer/README.md b/.devcontainer/README.md index cec62c802..1b7429c8d 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -89,6 +89,18 @@ Additional tasks are available for common maintenance operations: - **Migrate Database**: To apply database migrations. - **Create Superuser**: To create an admin user for the application. +## Committing from the Host Machine + +The DevContainer automatically installs pre-commit hooks during setup. However, these hooks are configured for use inside the container. + +If you want to commit changes from your host machine (outside the DevContainer), you need to set up pre-commit on your host. This installs it as a standalone tool. + +```bash +uv tool install pre-commit && pre-commit install +``` + +After this, you can commit either from inside the DevContainer or from your host machine. + ## Let's Get Started! Follow the steps above to get your development environment up and running. Happy coding! diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cec8e2177..aef29bd90 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,26 +3,30 @@ "dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml", "service": "paperless-development", "workspaceFolder": "/usr/src/paperless/paperless-ngx", - "postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'", + "containerEnv": { + "UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache" + }, + "postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'", "customizations": { "vscode": { - "extensions": [ - "mhutchie.git-graph", - "ms-python.python", - "ms-vscode.js-debug-nightly", - "eamodio.gitlens", - "yzhang.markdown-all-in-one" - ], - "settings": { - "python.defaultInterpreterPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python", - "python.pythonPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python", - "python.terminal.activateEnvInCurrentTerminal": true, - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } + "extensions": [ + "mhutchie.git-graph", + "ms-python.python", + "ms-vscode.js-debug-nightly", + "eamodio.gitlens", + "yzhang.markdown-all-in-one", + "pnpm.pnpm" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python", + "python.pythonPath": "/usr/src/paperless/paperless-ngx/.venv/bin/python", + "python.terminal.activateEnvInCurrentTerminal": true, + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } } - }, - "remoteUser": "paperless" - } + }, + "remoteUser": "paperless" +} diff --git a/.devcontainer/vscode/tasks.json b/.devcontainer/vscode/tasks.json index 6475e14d1..0cc954232 100644 --- a/.devcontainer/vscode/tasks.json +++ b/.devcontainer/vscode/tasks.json @@ -174,12 +174,22 @@ { "label": "Maintenance: Install Frontend Dependencies", "description": "Install frontend (pnpm) dependencies", - "type": "pnpm", - "script": "install", - "path": "src-ui", + "type": "shell", + "command": "pnpm install", "group": "clean", "problemMatcher": [], - "detail": "install dependencies from package" + "options": { + "cwd": "${workspaceFolder}/src-ui" + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "shared", + "showReuseMessage": false, + "clear": true, + "revealProblems": "onProblem" + } }, { "description": "Clean install frontend dependencies and build the frontend for production", diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 98c10396c..a619f01c1 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -75,9 +75,6 @@ jobs: env: NLTK_DATA: ${{ env.NLTK_DATA }} PAPERLESS_CI_TEST: 1 - PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }} - PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }} - PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }} run: | uv run \ --python ${{ steps.setup-python.outputs.python-version }} \ diff --git a/.gitignore b/.gitignore index c7b5c4d8e..715760b29 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ htmlcov/ .coverage .coverage.* .cache +.uv-cache nosetests.xml coverage.xml *,cover diff --git a/docker/compose/docker-compose.ci-test.yml b/docker/compose/docker-compose.ci-test.yml index d277406b8..f07f7fadb 100644 --- a/docker/compose/docker-compose.ci-test.yml +++ b/docker/compose/docker-compose.ci-test.yml @@ -23,3 +23,14 @@ services: container_name: tika network_mode: host restart: unless-stopped + greenmail: + image: greenmail/standalone:2.1.8 + hostname: greenmail + container_name: greenmail + environment: + # Enable only IMAP for now (SMTP available via 3025 if needed later) + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.imap -Dgreenmail.users=test@localhost:test -Dgreenmail.users.login=test@localhost -Dgreenmail.verbose + ports: + - "3143:3143" # IMAP + restart: unless-stopped diff --git a/docker/management_script.sh b/docker/management_script.sh index 1fa31c372..91a6336d0 100755 --- a/docker/management_script.sh +++ b/docker/management_script.sh @@ -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 diff --git a/docker/rootfs/usr/local/bin/convert_mariadb_uuid b/docker/rootfs/usr/local/bin/convert_mariadb_uuid index 806a98f3b..019c558f1 100755 --- a/docker/rootfs/usr/local/bin/convert_mariadb_uuid +++ b/docker/rootfs/usr/local/bin/convert_mariadb_uuid @@ -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 diff --git a/docker/rootfs/usr/local/bin/createsuperuser b/docker/rootfs/usr/local/bin/createsuperuser index f931952ba..2b56869f6 100755 --- a/docker/rootfs/usr/local/bin/createsuperuser +++ b/docker/rootfs/usr/local/bin/createsuperuser @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_archiver b/docker/rootfs/usr/local/bin/document_archiver index 383acfcc6..8d7771d26 100755 --- a/docker/rootfs/usr/local/bin/document_archiver +++ b/docker/rootfs/usr/local/bin/document_archiver @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_create_classifier b/docker/rootfs/usr/local/bin/document_create_classifier index 72dc33d6f..23acc6741 100755 --- a/docker/rootfs/usr/local/bin/document_create_classifier +++ b/docker/rootfs/usr/local/bin/document_create_classifier @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_exporter b/docker/rootfs/usr/local/bin/document_exporter index 7f48215d7..d55f01d48 100755 --- a/docker/rootfs/usr/local/bin/document_exporter +++ b/docker/rootfs/usr/local/bin/document_exporter @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_fuzzy_match b/docker/rootfs/usr/local/bin/document_fuzzy_match index 5b9548557..c6e4edadc 100755 --- a/docker/rootfs/usr/local/bin/document_fuzzy_match +++ b/docker/rootfs/usr/local/bin/document_fuzzy_match @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_importer b/docker/rootfs/usr/local/bin/document_importer index 2286e89f7..07c92bb04 100755 --- a/docker/rootfs/usr/local/bin/document_importer +++ b/docker/rootfs/usr/local/bin/document_importer @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_index b/docker/rootfs/usr/local/bin/document_index index 2d518b5c5..47c893c10 100755 --- a/docker/rootfs/usr/local/bin/document_index +++ b/docker/rootfs/usr/local/bin/document_index @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_renamer b/docker/rootfs/usr/local/bin/document_renamer index 326317a73..3406182ee 100755 --- a/docker/rootfs/usr/local/bin/document_renamer +++ b/docker/rootfs/usr/local/bin/document_renamer @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_retagger b/docker/rootfs/usr/local/bin/document_retagger index 3bab3e790..b0d1047ff 100755 --- a/docker/rootfs/usr/local/bin/document_retagger +++ b/docker/rootfs/usr/local/bin/document_retagger @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_sanity_checker b/docker/rootfs/usr/local/bin/document_sanity_checker index 5c0c29ef2..d792124fc 100755 --- a/docker/rootfs/usr/local/bin/document_sanity_checker +++ b/docker/rootfs/usr/local/bin/document_sanity_checker @@ -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 diff --git a/docker/rootfs/usr/local/bin/document_thumbnails b/docker/rootfs/usr/local/bin/document_thumbnails index c1000c31a..71d80e00d 100755 --- a/docker/rootfs/usr/local/bin/document_thumbnails +++ b/docker/rootfs/usr/local/bin/document_thumbnails @@ -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 diff --git a/docker/rootfs/usr/local/bin/mail_fetcher b/docker/rootfs/usr/local/bin/mail_fetcher index 2ae1d1dfb..654c07389 100755 --- a/docker/rootfs/usr/local/bin/mail_fetcher +++ b/docker/rootfs/usr/local/bin/mail_fetcher @@ -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 diff --git a/docker/rootfs/usr/local/bin/manage_superuser b/docker/rootfs/usr/local/bin/manage_superuser index 9f7f37ecf..a6e41168c 100755 --- a/docker/rootfs/usr/local/bin/manage_superuser +++ b/docker/rootfs/usr/local/bin/manage_superuser @@ -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 diff --git a/docker/rootfs/usr/local/bin/prune_audit_logs b/docker/rootfs/usr/local/bin/prune_audit_logs index b9142e98e..04446df17 100755 --- a/docker/rootfs/usr/local/bin/prune_audit_logs +++ b/docker/rootfs/usr/local/bin/prune_audit_logs @@ -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 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..ef252ad4a 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. @@ -1610,6 +1617,16 @@ processing. This only has an effect if Defaults to `0 1 * * *`, once per day. +## Share links + +#### [`PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON=`](#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON) {#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON} + +: Controls how often Paperless-ngx removes expired share link bundles (and their generated ZIP archives). + +: If set to the string "disable", expired bundles are not cleaned up automatically. + + Defaults to `0 2 * * *`, once per day at 02:00. + ## Binaries There are a few external software packages that Paperless expects to diff --git a/docs/usage.md b/docs/usage.md index 7da83a3e1..f652164da 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -308,12 +308,14 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook) ### Share Links -"Share links" are shareable public links to files and can be created and managed under the 'Send' button on the document detail screen. +"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor. -- Share links do not require a user to login and thus link directly to a file. +- Share links do not require a user to login and thus link directly to a file or bundled download. - Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`. - Links can optionally have an expiration time set. - After a link expires or is deleted users will be redirected to the regular paperless-ngx login. +- From the document detail screen you can create a share link for that single document. +- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links. !!! tip diff --git a/pyproject.toml b/pyproject.toml index 097e2c19b..ac6c39b2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,15 +114,16 @@ testing = [ "daphne", "factory-boy~=3.3.1", "imagehash", - "pytest~=8.4.1", + "pytest~=9.0.0", "pytest-cov~=7.0.0", "pytest-django~=4.11.1", - "pytest-env", + "pytest-env~=1.2.0", "pytest-httpx", - "pytest-mock", - "pytest-rerunfailures", + "pytest-mock~=3.15.1", + #"pytest-randomly~=4.0.1", + "pytest-rerunfailures~=16.1", "pytest-sugar", - "pytest-xdist", + "pytest-xdist~=3.8.0", ] lint = [ @@ -257,14 +258,18 @@ 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] -minversion = "8.0" -pythonpath = [ - "src", -] +[tool.pytest] +minversion = "9.0" +pythonpath = [ "src" ] + +strict_config = true +strict_markers = true +strict_parametrization_ids = true +strict_xfail = true + testpaths = [ "src/documents/tests/", "src/paperless/tests/", @@ -275,6 +280,7 @@ testpaths = [ "src/paperless_remote/tests/", "src/paperless_ai/tests", ] + addopts = [ "--pythonwarnings=all", "--cov", @@ -282,11 +288,14 @@ addopts = [ "--cov-report=xml", "--numprocesses=auto", "--maxprocesses=16", - "--quiet", + "--dist=loadscope", "--durations=50", + "--durations-min=0.5", "--junitxml=junit.xml", - "-o junit_family=legacy", + "-o", + "junit_family=legacy", ] + norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ] DJANGO_SETTINGS_MODULE = "paperless.settings" diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 5cab6203c..88de81f58 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -314,6 +314,14 @@ src/app/app.component.ts 152 + + src/app/components/admin/settings/settings.component.html + 193 + + + src/app/components/admin/settings/settings.component.html + 197 + src/app/components/app-frame/app-frame.component.html 94 @@ -322,6 +330,14 @@ src/app/components/app-frame/app-frame.component.html 96 + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 85 + + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 36 + src/app/components/document-list/document-list.component.ts 192 @@ -332,19 +348,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 +550,7 @@ src/app/components/document-detail/document-detail.component.html - 396 + 437 @@ -545,7 +561,7 @@ src/app/components/admin/settings/settings.component.html - 362 + 386 src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html @@ -593,7 +609,7 @@ src/app/components/document-detail/document-detail.component.html - 389 + 430 src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html @@ -761,7 +777,7 @@ src/app/components/document-detail/document-detail.component.html - 409 + 450 src/app/components/document-list/document-list.component.html @@ -789,19 +805,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 @@ -914,92 +930,132 @@ 99,100 - - Items per page - - src/app/components/admin/settings/settings.component.html - 107 - - Sidebar src/app/components/admin/settings/settings.component.html - 123 + 107 Use 'slim' sidebar (icons only) src/app/components/admin/settings/settings.component.html - 127 + 111 Dark mode src/app/components/admin/settings/settings.component.html - 134 + 118 Use system settings src/app/components/admin/settings/settings.component.html - 137 + 121 Enable dark mode src/app/components/admin/settings/settings.component.html - 138 + 122 Invert thumbnails in dark mode src/app/components/admin/settings/settings.component.html - 139 + 123 Theme Color src/app/components/admin/settings/settings.component.html - 145 + 129 Reset src/app/components/admin/settings/settings.component.html - 152 + 136 + + + + Global search + + src/app/components/admin/settings/settings.component.html + 142 + + + src/app/components/app-frame/global-search/global-search.component.ts + 122 + + + + Do not include advanced search results + + src/app/components/admin/settings/settings.component.html + 145 + + + + Full search links to + + src/app/components/admin/settings/settings.component.html + 151 + + + + Title and content search + + src/app/components/admin/settings/settings.component.html + 155 + + + + Advanced search + + src/app/components/admin/settings/settings.component.html + 156 + + + src/app/components/app-frame/global-search/global-search.component.html + 24 + + + src/app/components/document-list/filter-editor/filter-editor.component.ts + 208 Update checking src/app/components/admin/settings/settings.component.html - 157 + 161 Enable update checking src/app/components/admin/settings/settings.component.html - 160 + 164 What's this? src/app/components/admin/settings/settings.component.html - 161 + 165 src/app/components/common/page-header/page-header.component.html - 9 + 18 src/app/components/common/permissions-select/permissions-select.component.html @@ -1014,21 +1070,21 @@ Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. src/app/components/admin/settings/settings.component.html - 165,167 + 169,171 No tracking data is collected by the app in any way. src/app/components/admin/settings/settings.component.html - 169 + 173 Saved Views src/app/components/admin/settings/settings.component.html - 175 + 179 src/app/components/app-frame/app-frame.component.html @@ -1047,152 +1103,126 @@ Show warning when closing saved views with unsaved changes src/app/components/admin/settings/settings.component.html - 178 + 182 Show document counts in sidebar saved views src/app/components/admin/settings/settings.component.html - 179 + 183 + + + + Items per page + + src/app/components/admin/settings/settings.component.html + 200 Document editing src/app/components/admin/settings/settings.component.html - 185 + 212 Use PDF viewer provided by the browser src/app/components/admin/settings/settings.component.html - 189 + 215 This is usually faster for displaying large PDF documents, but it might not work on some browsers. src/app/components/admin/settings/settings.component.html - 189 + 215 Default zoom src/app/components/admin/settings/settings.component.html - 195 + 221 Fit width src/app/components/admin/settings/settings.component.html - 199 + 225 Fit page src/app/components/admin/settings/settings.component.html - 200 + 226 Only applies to the Paperless-ngx PDF viewer. src/app/components/admin/settings/settings.component.html - 202 + 228 Automatically remove inbox tag(s) on save src/app/components/admin/settings/settings.component.html - 208 + 234 Show document thumbnail during loading src/app/components/admin/settings/settings.component.html - 214 + 240 - - Global search + + Built-in fields to show: src/app/components/admin/settings/settings.component.html - 218 - - - src/app/components/app-frame/global-search/global-search.component.ts - 122 + 246 - - Do not include advanced search results + + Uncheck fields to hide them on the document details page. src/app/components/admin/settings/settings.component.html - 221 - - - - Full search links to - - src/app/components/admin/settings/settings.component.html - 227 - - - - Title and content search - - src/app/components/admin/settings/settings.component.html - 231 - - - - Advanced search - - src/app/components/admin/settings/settings.component.html - 232 - - - src/app/components/app-frame/global-search/global-search.component.html - 24 - - - src/app/components/document-list/filter-editor/filter-editor.component.ts - 208 + 258 Bulk editing src/app/components/admin/settings/settings.component.html - 237 + 263 Show confirmation dialogs src/app/components/admin/settings/settings.component.html - 240 + 266 Apply on close src/app/components/admin/settings/settings.component.html - 241 + 267 Notes src/app/components/admin/settings/settings.component.html - 245 + 271 src/app/components/document-list/document-list.component.html @@ -1211,14 +1241,14 @@ Enable notes src/app/components/admin/settings/settings.component.html - 248 + 274 Permissions src/app/components/admin/settings/settings.component.html - 259 + 283 src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html @@ -1234,7 +1264,7 @@ src/app/components/document-detail/document-detail.component.html - 365 + 375 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -1262,47 +1292,47 @@ 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 Default Permissions src/app/components/admin/settings/settings.component.html - 262 + 286 Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI. src/app/components/admin/settings/settings.component.html - 266,268 + 290,292 Default Owner src/app/components/admin/settings/settings.component.html - 273 + 297 Objects without an owner can be viewed and edited by all users src/app/components/admin/settings/settings.component.html - 277 + 301 src/app/components/common/input/permissions/permissions-form/permissions-form.component.html @@ -1313,18 +1343,18 @@ Default View Permissions src/app/components/admin/settings/settings.component.html - 282 + 306 Users: src/app/components/admin/settings/settings.component.html - 287 + 311 src/app/components/admin/settings/settings.component.html - 314 + 338 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1355,11 +1385,11 @@ Groups: src/app/components/admin/settings/settings.component.html - 297 + 321 src/app/components/admin/settings/settings.component.html - 324 + 348 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1390,14 +1420,14 @@ Default Edit Permissions src/app/components/admin/settings/settings.component.html - 309 + 333 Edit permissions also grant viewing permissions src/app/components/admin/settings/settings.component.html - 333 + 357 src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html @@ -1416,7 +1446,7 @@ Notifications src/app/components/admin/settings/settings.component.html - 341 + 365 src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html @@ -1427,49 +1457,49 @@ Document processing src/app/components/admin/settings/settings.component.html - 344 + 368 Show notifications when new documents are detected src/app/components/admin/settings/settings.component.html - 348 + 372 Show notifications when document processing completes successfully src/app/components/admin/settings/settings.component.html - 349 + 373 Show notifications when document processing fails src/app/components/admin/settings/settings.component.html - 350 + 374 Suppress notifications on dashboard src/app/components/admin/settings/settings.component.html - 351 + 375 This will suppress all messages about document processing status on the dashboard. src/app/components/admin/settings/settings.component.html - 351 + 375 Cancel src/app/components/admin/settings/settings.component.html - 361 + 385 src/app/components/common/confirm-dialog/confirm-dialog.component.ts @@ -1550,11 +1580,150 @@ 81 + + Archive serial number + + src/app/components/admin/settings/settings.component.ts + 95 + + + src/app/components/document-detail/document-detail.component.html + 150 + + + + Correspondent + + src/app/components/admin/settings/settings.component.ts + 97 + + + src/app/components/document-detail/document-detail.component.html + 155 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 19 + + + src/app/components/document-list/document-list.component.html + 211 + + + src/app/components/document-list/filter-editor/filter-editor.component.html + 50 + + + src/app/data/document.ts + 46 + + + src/app/data/document.ts + 89 + + + + Document type + + src/app/components/admin/settings/settings.component.ts + 98 + + + src/app/components/document-detail/document-detail.component.html + 159 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 33 + + + src/app/components/document-list/document-list.component.html + 251 + + + src/app/components/document-list/filter-editor/filter-editor.component.html + 61 + + + src/app/data/document.ts + 50 + + + src/app/data/document.ts + 91 + + + + Storage path + + src/app/components/admin/settings/settings.component.ts + 99 + + + src/app/components/document-detail/document-detail.component.html + 163 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 47 + + + src/app/components/document-list/document-list.component.html + 260 + + + src/app/components/document-list/filter-editor/filter-editor.component.html + 72 + + + src/app/data/document.ts + 54 + + + + Tags + + src/app/components/admin/settings/settings.component.ts + 100 + + + src/app/components/app-frame/app-frame.component.html + 188 + + + src/app/components/app-frame/app-frame.component.html + 191 + + + src/app/components/common/input/tags/tags.component.ts + 80 + + + src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html + 94 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 5 + + + src/app/components/document-list/document-list.component.html + 224 + + + src/app/components/document-list/filter-editor/filter-editor.component.html + 39 + + + src/app/data/document.ts + 42 + + Error retrieving users src/app/components/admin/settings/settings.component.ts - 226 + 248 src/app/components/admin/users-groups/users-groups.component.ts @@ -1565,7 +1734,7 @@ Error retrieving groups src/app/components/admin/settings/settings.component.ts - 245 + 267 src/app/components/admin/users-groups/users-groups.component.ts @@ -1576,28 +1745,28 @@ Settings were saved successfully. src/app/components/admin/settings/settings.component.ts - 548 + 577 Settings were saved successfully. Reload is required to apply some changes. src/app/components/admin/settings/settings.component.ts - 552 + 581 Reload now src/app/components/admin/settings/settings.component.ts - 553 + 582 An error occurred while saving settings. src/app/components/admin/settings/settings.component.ts - 563 + 592 src/app/components/app-frame/app-frame.component.ts @@ -1633,22 +1802,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 +1886,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 @@ -1782,6 +1935,10 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts 94 + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 32 + src/app/components/document-list/document-list.component.html 269 @@ -1831,6 +1988,10 @@ src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html 67 + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 38 + src/app/components/document-detail/document-detail.component.html 50 @@ -1853,19 +2014,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 +2044,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 +2066,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 @@ -2156,7 +2324,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 145 + 167 src/app/components/manage/custom-fields/custom-fields.component.html @@ -2184,55 +2352,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 +2434,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 +2480,7 @@ src/app/components/manage/management-list/management-list.component.ts - 368 + 388 src/app/components/manage/workflows/workflows.component.ts @@ -2503,35 +2671,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,23 +2775,23 @@ src/app/components/document-detail/document-detail.component.ts - 1098 + 1121 src/app/components/document-detail/document-detail.component.ts - 1463 + 1486 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 798 + 802 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 831 + 835 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 850 + 854 src/app/components/manage/custom-fields/custom-fields.component.ts @@ -2639,7 +2807,7 @@ src/app/components/manage/management-list/management-list.component.ts - 370 + 390 src/app/components/manage/workflows/workflows.component.ts @@ -2796,41 +2964,6 @@ 107 - - Tags - - src/app/components/app-frame/app-frame.component.html - 188 - - - src/app/components/app-frame/app-frame.component.html - 191 - - - src/app/components/common/input/tags/tags.component.ts - 80 - - - src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html - 94 - - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 5 - - - src/app/components/document-list/document-list.component.html - 224 - - - src/app/components/document-list/filter-editor/filter-editor.component.html - 39 - - - src/app/data/document.ts - 42 - - Document Types @@ -3048,7 +3181,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 117 + 139 src/app/components/document-list/document-card-large/document-card-large.component.html @@ -3237,31 +3370,31 @@ src/app/components/document-detail/document-detail.component.ts - 1051 + 1074 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 441 + 445 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 481 + 485 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 519 + 523 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 557 + 561 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 619 + 623 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 752 + 756 @@ -3342,7 +3475,7 @@ src/app/components/document-detail/document-detail.component.ts - 1514 + 1537 @@ -3353,7 +3486,7 @@ src/app/components/document-detail/document-detail.component.ts - 1515 + 1538 @@ -3364,7 +3497,7 @@ src/app/components/document-detail/document-detail.component.ts - 1516 + 1539 @@ -3453,7 +3586,7 @@ src/app/components/common/dates-dropdown/dates-dropdown.component.ts - 111 + 113 src/app/components/common/input/date/date.component.html @@ -3486,6 +3619,10 @@ src/app/components/common/input/date/date.component.html 22 + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 155 + src/app/components/document-detail/document-detail.component.html 103 @@ -3573,6 +3710,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 +3850,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 +3868,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 +4593,7 @@ src/app/components/manage/storage-path-list/storage-path-list.component.ts - 51 + 53 @@ -4458,7 +4611,7 @@ src/app/components/document-detail/document-detail.component.html - 331 + 341 @@ -4515,7 +4668,7 @@ src/app/components/manage/tag-list/tag-list.component.ts - 51 + 53 @@ -4573,7 +4726,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.html - 101 + 124 @@ -5257,84 +5410,105 @@ Has any of these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 203 + 209 Has all of these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 210 + 216 Does not have these tags src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 217 + 223 + + + + Has any of these correspondents + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 230 Has correspondent src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 224 + 238 Does not have correspondents src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 232 + 246 Has document type src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 240 + 254 + + + + Has any of these document types + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 262 Does not have document types src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 248 + 270 Has storage path src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 256 + 278 + + + + Has any of these storage paths + + src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts + 286 Does not have storage paths src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 264 + 294 Matches custom field query src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 272 + 302 Create new workflow src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 474 + 531 Edit workflow src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts - 478 + 535 @@ -5437,19 +5611,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 @@ -5734,11 +5908,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 @@ -6037,21 +6230,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 @@ -6228,6 +6406,301 @@ 326 + + Selected documents: + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 10 + + + + + more… + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 22 + + + + Expires + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 31 + + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 87 + + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 35 + + + src/app/components/common/share-links-dialog/share-links-dialog.component.html + 52 + + + + Share archive version (if available) + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 47 + + + + Share link bundle requested + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 54 + + + + You can copy the share link below or open the manager to monitor progress. The link will start working once the bundle is ready. + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 55,57 + + + + Status + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 60 + + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 33 + + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 58 + + + src/app/components/common/toast/toast.component.html + 28 + + + src/app/components/manage/mail/mail.component.html + 114 + + + src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html + 35 + + + src/app/components/manage/workflows/workflows.component.html + 19 + + + + Slug + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 64 + + + + Link + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 66 + + + + Copy link + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 81 + + + + Never + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 93 + + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 101 + + + src/app/data/share-link.ts + 17 + + + + File version + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 96 + + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 37 + + + + Size + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 99 + + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 34 + + + + A zip file containing the selected documents will be created for this share link bundle. This process happens in the background and may take some time, especially for large bundles. + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 109 + + + + Manage share link bundles + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html + 113 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 119 + + + + Create share link bundle + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts + 61 + + + + Create link + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts + 62 + + + + Share link copied to clipboard. + + src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts + 96 + + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts + 112 + + + + Loading share link bundles… + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 10 + + + + Status updates every few seconds while bundles are being prepared. + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 21 + + + + No share link bundles currently exist. + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 25 + + + + Built: + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 48 + + + + View error details + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 62 + + + + Copy share link + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 113 + + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 122 + + + + Retry + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 132 + + + + Delete share link bundle + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html + 141 + + + + Share link bundles + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts + 42 + + + + Failed to load share link bundles. + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts + 66 + + + + Error retrieving share link bundles. + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts + 68 + + + + Share link bundle deleted. + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts + 121 + + + + Error deleting share link bundle. + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts + 127 + + + + Share link bundle rebuild requested. + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts + 139 + + + + Error requesting rebuild. + + src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts + 144 + + No existing links @@ -6249,50 +6722,11 @@ 48 - - Expires - - src/app/components/common/share-links-dialog/share-links-dialog.component.html - 52 - - - - 1 day - - src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 25 - - - src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 102 - - - - 7 days - - src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 26 - - - - 30 days - - src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 27 - - - - Never - - src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 28 - - Share Links src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 32 + 31 src/app/components/document-detail/document-detail.component.html @@ -6303,28 +6737,39 @@ Error retrieving links src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 83 + 82 + + + + 1 day + + src/app/components/common/share-links-dialog/share-links-dialog.component.ts + 101 + + + src/app/data/share-link.ts + 14 days src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 102 + 101 Error deleting link src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 131 + 130 Error creating link src/app/components/common/share-links-dialog/share-links-dialog.component.ts - 159 + 158 @@ -6419,29 +6864,6 @@ 52 - - Status - - src/app/components/common/system-status-dialog/system-status-dialog.component.html - 58 - - - src/app/components/common/toast/toast.component.html - 28 - - - src/app/components/manage/mail/mail.component.html - 114 - - - src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html - 35 - - - src/app/components/manage/workflows/workflows.component.html - 19 - - Migration Status @@ -6821,7 +7243,7 @@ src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 386 + 390 this string is used to separate processing, failed and added on the file upload widget @@ -6877,6 +7299,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 @@ -6943,7 +7381,7 @@ src/app/components/document-detail/document-detail.component.ts - 1462 + 1485 @@ -6959,6 +7397,10 @@ src/app/components/document-detail/document-detail.component.html 80 + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 111 + Previous @@ -6997,102 +7439,18 @@ 90 - - Archive serial number - - src/app/components/document-detail/document-detail.component.html - 149 - - Date created - - src/app/components/document-detail/document-detail.component.html - 150 - - - - Correspondent src/app/components/document-detail/document-detail.component.html 152 - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 19 - - - src/app/components/document-list/document-list.component.html - 211 - - - src/app/components/document-list/filter-editor/filter-editor.component.html - 50 - - - src/app/data/document.ts - 46 - - - src/app/data/document.ts - 89 - - - - Document type - - src/app/components/document-detail/document-detail.component.html - 154 - - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 33 - - - src/app/components/document-list/document-list.component.html - 251 - - - src/app/components/document-list/filter-editor/filter-editor.component.html - 61 - - - src/app/data/document.ts - 50 - - - src/app/data/document.ts - 91 - - - - Storage path - - src/app/components/document-detail/document-detail.component.html - 156 - - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 47 - - - src/app/components/document-list/document-list.component.html - 260 - - - src/app/components/document-list/filter-editor/filter-editor.component.html - 72 - - - src/app/data/document.ts - 54 - Default src/app/components/document-detail/document-detail.component.html - 157 + 164 src/app/components/manage/saved-views/saved-views.component.html @@ -7103,14 +7461,14 @@ Content src/app/components/document-detail/document-detail.component.html - 261 + 271 Metadata src/app/components/document-detail/document-detail.component.html - 270 + 280 src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts @@ -7121,175 +7479,196 @@ Date modified src/app/components/document-detail/document-detail.component.html - 277 + 287 Date added src/app/components/document-detail/document-detail.component.html - 281 + 291 Media filename src/app/components/document-detail/document-detail.component.html - 285 + 295 Original filename src/app/components/document-detail/document-detail.component.html - 289 + 299 Original MD5 checksum src/app/components/document-detail/document-detail.component.html - 293 + 303 Original file size src/app/components/document-detail/document-detail.component.html - 297 + 307 Original mime type src/app/components/document-detail/document-detail.component.html - 301 + 311 Archive MD5 checksum src/app/components/document-detail/document-detail.component.html - 306 + 316 Archive file size src/app/components/document-detail/document-detail.component.html - 312 + 322 Original document metadata src/app/components/document-detail/document-detail.component.html - 321 + 331 Archived document metadata src/app/components/document-detail/document-detail.component.html - 324 + 334 Notes src/app/components/document-detail/document-detail.component.html - 343,346 + 353,356 History src/app/components/document-detail/document-detail.component.html - 354 + 364 + + + + Duplicates + + src/app/components/document-detail/document-detail.component.html + 386,390 + + + + Duplicate documents detected: + + src/app/components/document-detail/document-detail.component.html + 392 + + + + In trash + + src/app/components/document-detail/document-detail.component.html + 403 Save & next src/app/components/document-detail/document-detail.component.html - 391 + 432 Save & close src/app/components/document-detail/document-detail.component.html - 394 + 435 Document loading... src/app/components/document-detail/document-detail.component.html - 404 + 445 Enter Password src/app/components/document-detail/document-detail.component.html - 458 + 499 An error occurred loading content: src/app/components/document-detail/document-detail.component.ts - 430,432 + 441,443 Document changes detected src/app/components/document-detail/document-detail.component.ts - 464 + 480 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 + 481 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 + 482 Ok src/app/components/document-detail/document-detail.component.ts - 468 + 484 Next document src/app/components/document-detail/document-detail.component.ts - 594 + 610 Previous document src/app/components/document-detail/document-detail.component.ts - 604 + 620 Close document src/app/components/document-detail/document-detail.component.ts - 612 + 628 src/app/services/open-documents.service.ts @@ -7300,202 +7679,202 @@ Save document src/app/components/document-detail/document-detail.component.ts - 619 + 635 Save and close / next src/app/components/document-detail/document-detail.component.ts - 628 + 644 Error retrieving metadata src/app/components/document-detail/document-detail.component.ts - 683 + 699 Error retrieving suggestions. src/app/components/document-detail/document-detail.component.ts - 731 + 754 Document "" saved successfully. src/app/components/document-detail/document-detail.component.ts - 940 + 963 src/app/components/document-detail/document-detail.component.ts - 964 + 987 Error saving document "" src/app/components/document-detail/document-detail.component.ts - 970 + 993 Error saving document src/app/components/document-detail/document-detail.component.ts - 1020 + 1043 Do you really want to move the document "" to the trash? src/app/components/document-detail/document-detail.component.ts - 1052 + 1075 Documents can be restored prior to permanent deletion. src/app/components/document-detail/document-detail.component.ts - 1053 + 1076 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 754 + 758 Move to trash src/app/components/document-detail/document-detail.component.ts - 1055 + 1078 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 756 + 760 Error deleting document src/app/components/document-detail/document-detail.component.ts - 1074 + 1097 Reprocess confirm src/app/components/document-detail/document-detail.component.ts - 1094 + 1117 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 794 + 798 This operation will permanently recreate the archive file for this document. src/app/components/document-detail/document-detail.component.ts - 1095 + 1118 The archive file will be re-generated with the current settings. src/app/components/document-detail/document-detail.component.ts - 1096 + 1119 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 + 1129 Error executing operation src/app/components/document-detail/document-detail.component.ts - 1117 + 1140 Error downloading document src/app/components/document-detail/document-detail.component.ts - 1166 + 1189 Page Fit src/app/components/document-detail/document-detail.component.ts - 1243 + 1266 PDF edit operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1481 + 1504 Error executing PDF edit operation src/app/components/document-detail/document-detail.component.ts - 1493 + 1516 Please enter the current password before attempting to remove it. src/app/components/document-detail/document-detail.component.ts - 1504 + 1527 Password removal operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1536 + 1559 Error executing password removal operation src/app/components/document-detail/document-detail.component.ts - 1550 + 1573 Print failed. src/app/components/document-detail/document-detail.component.ts - 1587 + 1610 Error loading document for printing. src/app/components/document-detail/document-detail.component.ts - 1599 + 1622 An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 1664 + 1687 src/app/components/document-detail/document-detail.component.ts - 1668 + 1691 @@ -7599,57 +7978,64 @@ 97 + + Create a share link bundle + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 116 + + Include: src/app/components/document-list/bulk-editor/bulk-editor.component.html - 123 + 145 Archived files src/app/components/document-list/bulk-editor/bulk-editor.component.html - 127 + 149 Original files src/app/components/document-list/bulk-editor/bulk-editor.component.html - 131 + 153 Use formatted filename src/app/components/document-list/bulk-editor/bulk-editor.component.html - 136 + 158 Error executing bulk operation src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 290 + 294 "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 378 + 382 src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 384 + 388 "" and "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 380 + 384 This is for messages like 'modify "tag1" and "tag2"' @@ -7657,7 +8043,7 @@ and "" src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 388,390 + 392,394 this is for messages like 'modify "tag1", "tag2" and "tag3"' @@ -7665,14 +8051,14 @@ Confirm tags assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 405 + 409 This operation will add the tag "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 411 + 415 @@ -7681,14 +8067,14 @@ )"/> to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 416,418 + 420,422 This operation will remove the tag "" from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 424 + 428 @@ -7697,7 +8083,7 @@ )"/> from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 429,431 + 433,435 @@ -7708,84 +8094,84 @@ )"/> on selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 433,437 + 437,441 Confirm correspondent assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 474 + 478 This operation will assign the correspondent "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 476 + 480 This operation will remove the correspondent from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 478 + 482 Confirm document type assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 512 + 516 This operation will assign the document type "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 514 + 518 This operation will remove the document type from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 516 + 520 Confirm storage path assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 550 + 554 This operation will assign the storage path "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 552 + 556 This operation will remove the storage path from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 554 + 558 Confirm custom field assignment src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 583 + 587 This operation will assign the custom field "" to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 589 + 593 @@ -7794,14 +8180,14 @@ )"/> to selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 594,596 + 598,600 This operation will remove the custom field "" from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 602 + 606 @@ -7810,7 +8196,7 @@ )"/> from selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 607,609 + 611,613 @@ -7821,77 +8207,91 @@ )"/> on selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 611,615 + 615,619 Move selected document(s) to the trash? src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 753 + 757 This operation will permanently recreate the archive files for selected document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 795 + 799 The archive files will be re-generated with the current settings. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 796 + 800 Rotate confirm src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 828 + 832 This operation will permanently rotate the original version of document(s). src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 829 + 833 Merge confirm src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 848 + 852 This operation will merge selected documents into a new document. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 849 + 853 Merged document will be queued for consumption. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 868 + 872 Custom fields updated. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 892 + 896 Error updating custom fields. src/app/components/document-list/bulk-editor/bulk-editor.component.ts - 901 + 905 + + + + Share link bundle creation requested. + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 945 + + + + Share link bundle creation is not available yet. + + src/app/components/document-list/bulk-editor/bulk-editor.component.ts + 952 @@ -8050,6 +8450,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 @@ -8061,6 +8477,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 @@ -8072,6 +8504,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 @@ -8083,6 +8531,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: @@ -8090,6 +8554,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 @@ -8097,9 +8577,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 @@ -8665,28 +9161,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 @@ -8718,19 +9214,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 @@ -8772,21 +9268,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 @@ -9057,7 +9553,7 @@ src/app/components/manage/management-list/management-list.component.ts - 353 + 373 @@ -9099,83 +9595,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 @@ -9186,70 +9682,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 @@ -9326,42 +9822,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 @@ -9953,6 +10449,62 @@ 321 + + Pending + + src/app/data/share-link-bundle.ts + 41 + + + + Processing + + src/app/data/share-link-bundle.ts + 42 + + + + Ready + + src/app/data/share-link-bundle.ts + 43 + + + + Failed + + src/app/data/share-link-bundle.ts + 44 + + + + Archive + + src/app/data/share-link-bundle.ts + 51 + + + + Original + + src/app/data/share-link-bundle.ts + 52 + + + + 7 days + + src/app/data/share-link.ts + 15 + + + + 30 days + + src/app/data/share-link.ts + 16 + + Warning: You have unsaved changes to your document(s). diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html index b228dac32..807368aa6 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.html +++ b/src-ui/src/app/components/admin/settings/settings.component.html @@ -103,22 +103,6 @@
-
- Items per page -
-
- - - -
-
- -
Sidebar
@@ -153,8 +137,28 @@
+ +
+
Global search
+
+
+ +
+
-
Update checking
+
+
+ Full search links to +
+
+ +
+
+ +
Update checking
@@ -179,11 +183,33 @@
-
-
-
Document editing
+
+ + + +
  • + Documents + +
    +
    +
    Documents
    +
    +
    + Items per page +
    +
    + +
    +
    + +
    Document editing
    @@ -209,31 +235,31 @@
    -
    +
    -
    Global search
    -
    -
    - -
    -
    -
    -
    - Full search links to -
    -
    - +
    +

    Built-in fields to show:

    + @for (option of documentDetailFieldOptions; track option.id) { +
    + + +
    + } +

    Uncheck fields to hide them on the document details page.

    - +
    +
    Bulk editing
    @@ -248,10 +274,8 @@
    -
    -
  • diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 650d6d8ea..62a5aa363 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -201,9 +201,9 @@ describe('SettingsComponent', () => { const navigateSpy = jest.spyOn(router, 'navigate') const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) - expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) + expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents']) tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) - expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) + expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) const initSpy = jest.spyOn(component, 'initialize') component.isDirty = true // mock dirty @@ -213,8 +213,8 @@ describe('SettingsComponent', () => { expect(initSpy).not.toHaveBeenCalled() navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty - tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) - expect(navigateSpy).toHaveBeenCalledWith(['settings', 'notifications']) + tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) + expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) expect(initSpy).toHaveBeenCalled() }) @@ -226,7 +226,7 @@ describe('SettingsComponent', () => { activatedRoute.snapshot.fragment = '#notifications' const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') component.ngOnInit() - expect(component.activeNavID).toEqual(3) // Notifications + expect(component.activeNavID).toEqual(4) // Notifications component.ngAfterViewInit() expect(scrollSpy).toHaveBeenCalledWith('#notifications') }) @@ -251,7 +251,7 @@ describe('SettingsComponent', () => { expect(toastErrorSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled() - expect(setSpy).toHaveBeenCalledTimes(30) + expect(setSpy).toHaveBeenCalledTimes(31) // succeed storeSpy.mockReturnValueOnce(of(true)) @@ -366,4 +366,22 @@ describe('SettingsComponent', () => { settingsService.settingsSaved.emit(true) expect(maybeRefreshSpy).toHaveBeenCalled() }) + + it('should support toggling document detail fields', () => { + completeSetup() + const field = 'storage_path' + expect( + component.settingsForm.get('documentDetailsHiddenFields').value.length + ).toEqual(0) + component.toggleDocumentDetailField(field, false) + expect( + component.settingsForm.get('documentDetailsHiddenFields').value.length + ).toEqual(1) + expect(component.isDocumentDetailFieldShown(field)).toBeFalsy() + component.toggleDocumentDetailField(field, true) + expect( + component.settingsForm.get('documentDetailsHiddenFields').value.length + ).toEqual(0) + expect(component.isDocumentDetailFieldShown(field)).toBeTruthy() + }) }) diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index 614d2fcd0..990944ff6 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -70,9 +70,9 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission enum SettingsNavIDs { General = 1, - Permissions = 2, - Notifications = 3, - SavedViews = 4, + Documents = 2, + Permissions = 3, + Notifications = 4, } const systemLanguage = { code: '', name: $localize`Use system language` } @@ -81,6 +81,25 @@ const systemDateFormat = { name: $localize`Use date format of display language`, } +export enum DocumentDetailFieldID { + ArchiveSerialNumber = 'archive_serial_number', + Correspondent = 'correspondent', + DocumentType = 'document_type', + StoragePath = 'storage_path', + Tags = 'tags', +} + +const documentDetailFieldOptions = [ + { + id: DocumentDetailFieldID.ArchiveSerialNumber, + label: $localize`Archive serial number`, + }, + { id: DocumentDetailFieldID.Correspondent, label: $localize`Correspondent` }, + { id: DocumentDetailFieldID.DocumentType, label: $localize`Document type` }, + { id: DocumentDetailFieldID.StoragePath, label: $localize`Storage path` }, + { id: DocumentDetailFieldID.Tags, label: $localize`Tags` }, +] + @Component({ selector: 'pngx-settings', templateUrl: './settings.component.html', @@ -146,6 +165,7 @@ export class SettingsComponent pdfViewerDefaultZoom: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null), + documentDetailsHiddenFields: new FormControl([]), searchDbOnly: new FormControl(null), searchLink: new FormControl(null), @@ -176,6 +196,8 @@ export class SettingsComponent public readonly ZoomSetting = ZoomSetting + public readonly documentDetailFieldOptions = documentDetailFieldOptions + get systemStatusHasErrors(): boolean { return ( this.systemStatus.database.status === SystemStatusItemStatus.ERROR || @@ -336,6 +358,9 @@ export class SettingsComponent documentEditingOverlayThumbnail: this.settings.get( SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL ), + documentDetailsHiddenFields: this.settings.get( + SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS + ), searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY), searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE), } @@ -526,6 +551,10 @@ export class SettingsComponent SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, this.settingsForm.value.documentEditingOverlayThumbnail ) + this.settings.set( + SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, + this.settingsForm.value.documentDetailsHiddenFields + ) this.settings.set( SETTINGS_KEYS.SEARCH_DB_ONLY, this.settingsForm.value.searchDbOnly @@ -587,6 +616,26 @@ export class SettingsComponent this.settingsForm.get('themeColor').patchValue('') } + isDocumentDetailFieldShown(fieldId: string): boolean { + const hiddenFields = + this.settingsForm.value.documentDetailsHiddenFields || [] + return !hiddenFields.includes(fieldId) + } + + toggleDocumentDetailField(fieldId: string, checked: boolean) { + const hiddenFields = new Set( + this.settingsForm.value.documentDetailsHiddenFields || [] + ) + if (checked) { + hiddenFields.delete(fieldId) + } else { + hiddenFields.add(fieldId) + } + this.settingsForm + .get('documentDetailsHiddenFields') + .setValue(Array.from(hiddenFields)) + } + showSystemStatus() { const modal: NgbModalRef = this.modalService.open( SystemStatusDialogComponent, 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/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index fafc9e876..ac8a5d2c7 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -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( [] ) diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 74221e3f0..94d8318e0 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -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 = { 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 = { 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 = { 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, 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) { +

    + + diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts new file mode 100644 index 000000000..da4d93c6a --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.spec.ts @@ -0,0 +1,161 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { FileVersion } from 'src/app/data/share-link' +import { + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ShareLinkBundleDialogComponent } from './share-link-bundle-dialog.component' + +class MockToastService { + showInfo = jest.fn() + showError = jest.fn() +} + +describe('ShareLinkBundleDialogComponent', () => { + let component: ShareLinkBundleDialogComponent + let fixture: ComponentFixture + let clipboard: Clipboard + let toastService: MockToastService + let activeModal: NgbActiveModal + let originalApiBaseUrl: string + + beforeEach(() => { + originalApiBaseUrl = environment.apiBaseUrl + toastService = new MockToastService() + + TestBed.configureTestingModule({ + imports: [ + ShareLinkBundleDialogComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + NgbActiveModal, + { provide: ToastService, useValue: toastService }, + ], + }) + + fixture = TestBed.createComponent(ShareLinkBundleDialogComponent) + component = fixture.componentInstance + clipboard = TestBed.inject(Clipboard) + activeModal = TestBed.inject(NgbActiveModal) + fixture.detectChanges() + }) + + afterEach(() => { + jest.clearAllTimers() + environment.apiBaseUrl = originalApiBaseUrl + }) + + it('builds payload and emits confirm on submit', () => { + const confirmSpy = jest.spyOn(component.confirmClicked, 'emit') + component.documents = [ + { id: 1, title: 'Doc 1' } as any, + { id: 2, title: 'Doc 2' } as any, + ] + component.form.setValue({ + shareArchiveVersion: false, + expirationDays: 3, + }) + + component.submit() + + expect(component.payload).toEqual({ + document_ids: [1, 2], + file_version: FileVersion.Original, + expiration_days: 3, + }) + expect(component.buttonsEnabled).toBe(false) + expect(confirmSpy).toHaveBeenCalled() + + component.form.setValue({ + shareArchiveVersion: true, + expirationDays: 7, + }) + component.submit() + + expect(component.payload).toEqual({ + document_ids: [1, 2], + file_version: FileVersion.Archive, + expiration_days: 7, + }) + }) + + it('ignores submit when bundle already created', () => { + component.createdBundle = { id: 1 } as ShareLinkBundleSummary + const confirmSpy = jest.spyOn(component, 'confirm') + component.submit() + expect(confirmSpy).not.toHaveBeenCalled() + }) + + it('limits preview to ten documents', () => { + const docs = Array.from({ length: 12 }).map((_, index) => ({ + id: index + 1, + })) + component.documents = docs as any + + expect(component.selectionCount).toBe(12) + expect(component.documentPreview).toHaveLength(10) + expect(component.documentPreview[0].id).toBe(1) + }) + + it('copies share link and resets state after timeout', fakeAsync(() => { + const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true) + const bundle = { + slug: 'bundle-slug', + status: ShareLinkBundleStatus.Ready, + } as ShareLinkBundleSummary + + component.copy(bundle) + + expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle)) + expect(component.copied).toBe(true) + expect(toastService.showInfo).toHaveBeenCalled() + + tick(3000) + expect(component.copied).toBe(false) + })) + + it('generates share URLs based on API base URL', () => { + environment.apiBaseUrl = 'https://example.com/api/' + expect( + component.getShareUrl({ slug: 'abc' } as ShareLinkBundleSummary) + ).toBe('https://example.com/share/abc') + }) + + it('opens manage dialog when callback provided', () => { + const manageSpy = jest.fn() + component.onOpenManage = manageSpy + component.openManage() + expect(manageSpy).toHaveBeenCalled() + }) + + it('falls back to cancel when manage callback missing', () => { + const cancelSpy = jest.spyOn(component, 'cancel') + component.onOpenManage = undefined + component.openManage() + expect(cancelSpy).toHaveBeenCalled() + }) + + it('maps status and file version labels', () => { + expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain( + 'Processing' + ) + expect(component.fileVersionLabel(FileVersion.Archive)).toContain('Archive') + }) + + it('closes dialog when cancel invoked', () => { + const closeSpy = jest.spyOn(activeModal, 'close') + component.cancel() + expect(closeSpy).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts new file mode 100644 index 000000000..37aa70950 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.ts @@ -0,0 +1,118 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { CommonModule } from '@angular/common' +import { Component, Input, inject } from '@angular/core' +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Document } from 'src/app/data/document' +import { + FileVersion, + SHARE_LINK_EXPIRATION_OPTIONS, +} from 'src/app/data/share-link' +import { + SHARE_LINK_BUNDLE_FILE_VERSION_LABELS, + SHARE_LINK_BUNDLE_STATUS_LABELS, + ShareLinkBundleCreatePayload, + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' +import { FileSizePipe } from 'src/app/pipes/file-size.pipe' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component' + +@Component({ + selector: 'pngx-share-link-bundle-dialog', + templateUrl: './share-link-bundle-dialog.component.html', + imports: [ + CommonModule, + ReactiveFormsModule, + NgxBootstrapIconsModule, + FileSizePipe, + DocumentTitlePipe, + ], + providers: [], +}) +export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent { + private readonly formBuilder = inject(FormBuilder) + private readonly clipboard = inject(Clipboard) + private readonly toastService = inject(ToastService) + + private _documents: Document[] = [] + + selectionCount = 0 + documentPreview: Document[] = [] + form: FormGroup = this.formBuilder.group({ + shareArchiveVersion: true, + expirationDays: [7], + }) + payload: ShareLinkBundleCreatePayload | null = null + + readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS + + createdBundle: ShareLinkBundleSummary | null = null + copied = false + onOpenManage?: () => void + readonly statuses = ShareLinkBundleStatus + + constructor() { + super() + this.loading = false + this.title = $localize`Create share link bundle` + this.btnCaption = $localize`Create link` + } + + @Input() + set documents(docs: Document[]) { + this._documents = docs.concat() + this.selectionCount = this._documents.length + this.documentPreview = this._documents.slice(0, 10) + } + + submit() { + if (this.createdBundle) return + this.payload = { + document_ids: this._documents.map((doc) => doc.id), + file_version: this.form.value.shareArchiveVersion + ? FileVersion.Archive + : FileVersion.Original, + expiration_days: this.form.value.expirationDays, + } + this.buttonsEnabled = false + super.confirm() + } + + getShareUrl(bundle: ShareLinkBundleSummary): string { + const apiURL = new URL(environment.apiBaseUrl) + return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${ + bundle.slug + }` + } + + copy(bundle: ShareLinkBundleSummary): void { + const success = this.clipboard.copy(this.getShareUrl(bundle)) + if (success) { + this.copied = true + this.toastService.showInfo($localize`Share link copied to clipboard.`) + setTimeout(() => { + this.copied = false + }, 3000) + } + } + + openManage(): void { + if (this.onOpenManage) { + this.onOpenManage() + } else { + this.cancel() + } + } + + statusLabel(status: ShareLinkBundleSummary['status']): string { + return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status + } + + fileVersionLabel(version: FileVersion): string { + return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version + } +} diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html new file mode 100644 index 000000000..2f2155412 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.html @@ -0,0 +1,156 @@ + + + + + diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss new file mode 100644 index 000000000..c8ffc4d5d --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.scss @@ -0,0 +1,4 @@ +:host ::ng-deep .popover { + min-width: 300px; + max-width: 400px; + } diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts new file mode 100644 index 000000000..113cd65a3 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.spec.ts @@ -0,0 +1,251 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { of, throwError } from 'rxjs' +import { FileVersion } from 'src/app/data/share-link' +import { + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component' + +class MockShareLinkBundleService { + listAllBundles = jest.fn() + delete = jest.fn() + rebuildBundle = jest.fn() +} + +class MockToastService { + showInfo = jest.fn() + showError = jest.fn() +} + +describe('ShareLinkBundleManageDialogComponent', () => { + let component: ShareLinkBundleManageDialogComponent + let fixture: ComponentFixture + let service: MockShareLinkBundleService + let toastService: MockToastService + let clipboard: Clipboard + let activeModal: NgbActiveModal + let originalApiBaseUrl: string + + beforeEach(() => { + service = new MockShareLinkBundleService() + toastService = new MockToastService() + originalApiBaseUrl = environment.apiBaseUrl + + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(of(true)) + service.rebuildBundle.mockReturnValue(of(sampleBundle())) + + TestBed.configureTestingModule({ + imports: [ + ShareLinkBundleManageDialogComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [ + NgbActiveModal, + { provide: ShareLinkBundleService, useValue: service }, + { provide: ToastService, useValue: toastService }, + ], + }) + + fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent) + component = fixture.componentInstance + clipboard = TestBed.inject(Clipboard) + activeModal = TestBed.inject(NgbActiveModal) + }) + + afterEach(() => { + component.ngOnDestroy() + fixture.destroy() + environment.apiBaseUrl = originalApiBaseUrl + jest.clearAllMocks() + }) + + const sampleBundle = (overrides: Partial = {}) => + ({ + id: 1, + slug: 'bundle-slug', + created: new Date().toISOString(), + document_count: 1, + documents: [1], + status: ShareLinkBundleStatus.Pending, + file_version: FileVersion.Archive, + last_error: undefined, + ...overrides, + }) as ShareLinkBundleSummary + + it('loads bundles on init and polls periodically', fakeAsync(() => { + const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })] + service.listAllBundles.mockReset() + service.listAllBundles + .mockReturnValueOnce(of(bundles)) + .mockReturnValue(of(bundles)) + + fixture.detectChanges() + tick() + + expect(service.listAllBundles).toHaveBeenCalledTimes(1) + expect(component.bundles).toEqual(bundles) + expect(component.loading).toBe(false) + expect(component.error).toBeNull() + + tick(5000) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + })) + + it('handles errors when loading bundles', fakeAsync(() => { + service.listAllBundles.mockReset() + service.listAllBundles + .mockReturnValueOnce(throwError(() => new Error('load fail'))) + .mockReturnValue(of([])) + + fixture.detectChanges() + tick() + + expect(component.error).toContain('Failed to load share link bundles.') + expect(toastService.showError).toHaveBeenCalled() + expect(component.loading).toBe(false) + + tick(5000) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + })) + + it('copies bundle links when ready', fakeAsync(() => { + jest.spyOn(clipboard, 'copy').mockReturnValue(true) + fixture.detectChanges() + tick() + + const readyBundle = sampleBundle({ + slug: 'ready-slug', + status: ShareLinkBundleStatus.Ready, + }) + component.copy(readyBundle) + + expect(clipboard.copy).toHaveBeenCalledWith( + component.getShareUrl(readyBundle) + ) + expect(component.copiedSlug).toBe('ready-slug') + expect(toastService.showInfo).toHaveBeenCalled() + + tick(3000) + expect(component.copiedSlug).toBeNull() + })) + + it('ignores copy requests for non-ready bundles', fakeAsync(() => { + const copySpy = jest.spyOn(clipboard, 'copy') + fixture.detectChanges() + tick() + component.copy(sampleBundle({ status: ShareLinkBundleStatus.Pending })) + expect(copySpy).not.toHaveBeenCalled() + })) + + it('deletes bundles and refreshes list', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(of(true)) + + fixture.detectChanges() + tick() + + component.delete(sampleBundle()) + tick() + + expect(service.delete).toHaveBeenCalled() + expect(toastService.showInfo).toHaveBeenCalledWith( + expect.stringContaining('deleted.') + ) + expect(service.listAllBundles).toHaveBeenCalledTimes(2) + expect(component.loading).toBe(false) + })) + + it('handles delete errors gracefully', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.delete.mockReturnValue(throwError(() => new Error('delete fail'))) + + fixture.detectChanges() + tick() + + component.delete(sampleBundle()) + tick() + + expect(toastService.showError).toHaveBeenCalled() + expect(component.loading).toBe(false) + })) + + it('retries bundle build and replaces existing entry', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready }) + service.rebuildBundle.mockReturnValue(of(updated)) + + fixture.detectChanges() + tick() + + component.bundles = [sampleBundle()] + component.retry(component.bundles[0]) + tick() + + expect(service.rebuildBundle).toHaveBeenCalledWith(updated.id) + expect(component.bundles[0].status).toBe(ShareLinkBundleStatus.Ready) + expect(toastService.showInfo).toHaveBeenCalled() + })) + + it('adds new bundle when retry returns unknown entry', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.rebuildBundle.mockReturnValue( + of(sampleBundle({ id: 99, slug: 'new-slug' })) + ) + + fixture.detectChanges() + tick() + + component.bundles = [sampleBundle()] + component.retry({ id: 99 } as ShareLinkBundleSummary) + tick() + + expect(component.bundles.find((bundle) => bundle.id === 99)).toBeTruthy() + })) + + it('handles retry errors', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail'))) + + fixture.detectChanges() + tick() + + component.retry(sampleBundle()) + tick() + + expect(toastService.showError).toHaveBeenCalled() + })) + + it('maps helpers and closes dialog', fakeAsync(() => { + service.listAllBundles.mockReturnValue(of([])) + fixture.detectChanges() + tick() + + expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain( + 'Processing' + ) + expect(component.fileVersionLabel(FileVersion.Original)).toContain( + 'Original' + ) + + environment.apiBaseUrl = 'https://example.com/api/' + const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' })) + expect(url).toBe('https://example.com/share/sluggy') + + const closeSpy = jest.spyOn(activeModal, 'close') + component.close() + expect(closeSpy).toHaveBeenCalled() + })) +}) diff --git a/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts new file mode 100644 index 000000000..6eef144f9 --- /dev/null +++ b/src-ui/src/app/components/common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component.ts @@ -0,0 +1,177 @@ +import { Clipboard } from '@angular/cdk/clipboard' +import { CommonModule } from '@angular/common' +import { Component, OnDestroy, OnInit, inject } from '@angular/core' +import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs' +import { FileVersion } from 'src/app/data/share-link' +import { + SHARE_LINK_BUNDLE_FILE_VERSION_LABELS, + SHARE_LINK_BUNDLE_STATUS_LABELS, + ShareLinkBundleStatus, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { FileSizePipe } from 'src/app/pipes/file-size.pipe' +import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' +import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component' + +@Component({ + selector: 'pngx-share-link-bundle-manage-dialog', + templateUrl: './share-link-bundle-manage-dialog.component.html', + styleUrls: ['./share-link-bundle-manage-dialog.component.scss'], + imports: [ + ConfirmButtonComponent, + CommonModule, + NgbPopoverModule, + NgxBootstrapIconsModule, + FileSizePipe, + ], +}) +export class ShareLinkBundleManageDialogComponent + extends LoadingComponentWithPermissions + implements OnInit, OnDestroy +{ + private readonly activeModal = inject(NgbActiveModal) + private readonly shareLinkBundleService = inject(ShareLinkBundleService) + private readonly toastService = inject(ToastService) + private readonly clipboard = inject(Clipboard) + + title = $localize`Share link bundles` + + bundles: ShareLinkBundleSummary[] = [] + error: string | null = null + copiedSlug: string | null = null + + readonly statuses = ShareLinkBundleStatus + readonly fileVersions = FileVersion + + private readonly refresh$ = new Subject() + + ngOnInit(): void { + this.refresh$ + .pipe( + switchMap((silent) => { + if (!silent) { + this.loading = true + } + this.error = null + return this.shareLinkBundleService.listAllBundles().pipe( + catchError((error) => { + if (!silent) { + this.loading = false + } + this.error = $localize`Failed to load share link bundles.` + this.toastService.showError( + $localize`Error retrieving share link bundles.`, + error + ) + return of(null) + }) + ) + }), + takeUntil(this.unsubscribeNotifier) + ) + .subscribe((results) => { + if (results) { + this.bundles = results + this.copiedSlug = null + } + this.loading = false + }) + + this.triggerRefresh(false) + timer(5000, 5000) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => this.triggerRefresh(true)) + } + + ngOnDestroy(): void { + super.ngOnDestroy() + } + + getShareUrl(bundle: ShareLinkBundleSummary): string { + const apiURL = new URL(environment.apiBaseUrl) + return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${ + bundle.slug + }` + } + + copy(bundle: ShareLinkBundleSummary): void { + if (bundle.status !== ShareLinkBundleStatus.Ready) { + return + } + const success = this.clipboard.copy(this.getShareUrl(bundle)) + if (success) { + this.copiedSlug = bundle.slug + setTimeout(() => { + this.copiedSlug = null + }, 3000) + this.toastService.showInfo($localize`Share link copied to clipboard.`) + } + } + + delete(bundle: ShareLinkBundleSummary): void { + this.error = null + this.loading = true + this.shareLinkBundleService.delete(bundle).subscribe({ + next: () => { + this.toastService.showInfo($localize`Share link bundle deleted.`) + this.triggerRefresh(false) + }, + error: (e) => { + this.loading = false + this.toastService.showError( + $localize`Error deleting share link bundle.`, + e + ) + }, + }) + } + + retry(bundle: ShareLinkBundleSummary): void { + this.error = null + this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({ + next: (updated) => { + this.toastService.showInfo( + $localize`Share link bundle rebuild requested.` + ) + this.replaceBundle(updated) + }, + error: (e) => { + this.toastService.showError($localize`Error requesting rebuild.`, e) + }, + }) + } + + statusLabel(status: ShareLinkBundleStatus): string { + return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status + } + + fileVersionLabel(version: FileVersion): string { + return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version + } + + close(): void { + this.activeModal.close() + } + + private replaceBundle(updated: ShareLinkBundleSummary): void { + const index = this.bundles.findIndex((bundle) => bundle.id === updated.id) + if (index >= 0) { + this.bundles = [ + ...this.bundles.slice(0, index), + updated, + ...this.bundles.slice(index + 1), + ] + } else { + this.bundles = [updated, ...this.bundles] + } + } + + private triggerRefresh(silent: boolean): void { + this.refresh$.next(silent) + } +} diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html index fe3f9b9c3..e41a897a8 100644 --- a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.html @@ -51,7 +51,7 @@
    diff --git a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts index ffe11808c..9df3d438b 100644 --- a/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts +++ b/src-ui/src/app/components/common/share-links-dialog/share-links-dialog.component.ts @@ -4,7 +4,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { first } from 'rxjs' -import { FileVersion, ShareLink } from 'src/app/data/share-link' +import { + FileVersion, + SHARE_LINK_EXPIRATION_OPTIONS, + ShareLink, +} from 'src/app/data/share-link' import { ShareLinkService } from 'src/app/services/rest/share-link.service' import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' @@ -21,12 +25,7 @@ export class ShareLinksDialogComponent implements OnInit { private toastService = inject(ToastService) private clipboard = inject(Clipboard) - EXPIRATION_OPTIONS = [ - { label: $localize`1 day`, value: 1 }, - { label: $localize`7 days`, value: 7 }, - { label: $localize`30 days`, value: 30 }, - { label: $localize`Never`, value: null }, - ] + readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS @Input() title = $localize`Share Links` diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 44304c942..306152cc4 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -1,4 +1,4 @@ - + @if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) { @if (previewNumPages) {
    @@ -146,16 +146,26 @@
    - + @if (!isFieldHidden(DocumentDetailFieldID.ArchiveSerialNumber)) { + + } - - - - + @if (!isFieldHidden(DocumentDetailFieldID.Correspondent)) { + + } + @if (!isFieldHidden(DocumentDetailFieldID.DocumentType)) { + + } + @if (!isFieldHidden(DocumentDetailFieldID.StoragePath)) { + + } + @if (!isFieldHidden(DocumentDetailFieldID.Tags)) { + + } @for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
    @switch (getCustomFieldFromInstance(fieldInstance)?.data_type) { @@ -370,6 +380,37 @@ } + + @if (document?.duplicate_documents?.length) { +
  • + + Duplicates + {{ document.duplicate_documents.length }} + + +
    +
    Duplicate documents detected:
    + +
    +
    +
  • + }
    diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 198e7a7a4..809478816 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -48,6 +48,7 @@ import { } from 'src/app/data/filter-rule-type' import { StoragePath } from 'src/app/data/storage-path' import { Tag } from 'src/app/data/tag' +import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' @@ -301,16 +302,16 @@ describe('DocumentDetailComponent', () => { .spyOn(openDocumentsService, 'openDocument') .mockReturnValueOnce(of(true)) fixture.detectChanges() - expect(component.activeNavID).toEqual(5) // DocumentDetailNavIDs.Notes + expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes) }) it('should change url on tab switch', () => { initNormally() const navigateSpy = jest.spyOn(router, 'navigate') - component.nav.select(5) + component.nav.select(component.DocumentDetailNavIDs.Notes) component.nav.navChange.next({ activeId: 1, - nextId: 5, + nextId: component.DocumentDetailNavIDs.Notes, preventDefault: () => {}, }) fixture.detectChanges() @@ -352,6 +353,18 @@ describe('DocumentDetailComponent', () => { expect(component.document).toEqual(doc) }) + it('should fall back to details tab when duplicates tab is active but no duplicates', () => { + initNormally() + component.activeNavID = component.DocumentDetailNavIDs.Duplicates + const noDupDoc = { ...doc, duplicate_documents: [] } + + component.updateComponent(noDupDoc) + + expect(component.activeNavID).toEqual( + component.DocumentDetailNavIDs.Details + ) + }) + it('should load already-opened document via param', () => { initNormally() jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc)) @@ -367,6 +380,38 @@ describe('DocumentDetailComponent', () => { expect(component.document).toEqual(doc) }) + it('should update cached open document duplicates when reloading an open doc', () => { + const openDoc = { ...doc, duplicate_documents: [{ id: 1, title: 'Old' }] } + const updatedDuplicates = [ + { id: 2, title: 'Newer duplicate', deleted_at: null }, + ] + jest + .spyOn(activatedRoute, 'paramMap', 'get') + .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) + jest.spyOn(documentService, 'get').mockReturnValue( + of({ + ...doc, + modified: new Date('2024-01-02T00:00:00Z'), + duplicate_documents: updatedDuplicates, + }) + ) + jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) + const saveSpy = jest.spyOn(openDocumentsService, 'save') + jest.spyOn(openDocumentsService, 'openDocument').mockReturnValue(of(true)) + jest.spyOn(customFieldsService, 'listAll').mockReturnValue( + of({ + count: customFields.length, + all: customFields.map((f) => f.id), + results: customFields, + }) + ) + + fixture.detectChanges() + + expect(openDoc.duplicate_documents).toEqual(updatedDuplicates) + expect(saveSpy).toHaveBeenCalled() + }) + it('should disable form if user cannot edit', () => { currentUserHasObjectPermissions = false initNormally() @@ -971,7 +1016,7 @@ describe('DocumentDetailComponent', () => { it('should display built-in pdf viewer if not disabled', () => { initNormally() component.document.archived_file_name = 'file.pdf' - jest.spyOn(settingsService, 'get').mockReturnValue(false) + settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) expect(component.useNativePdfViewer).toBeFalsy() fixture.detectChanges() expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull() @@ -980,7 +1025,7 @@ describe('DocumentDetailComponent', () => { it('should display native pdf viewer if enabled', () => { initNormally() component.document.archived_file_name = 'file.pdf' - jest.spyOn(settingsService, 'get').mockReturnValue(true) + settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true) expect(component.useNativePdfViewer).toBeTruthy() fixture.detectChanges() expect(fixture.debugElement.query(By.css('object'))).not.toBeNull() diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 5bac6fe72..8c22f53c2 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -8,7 +8,7 @@ import { FormsModule, ReactiveFormsModule, } from '@angular/forms' -import { ActivatedRoute, Router } from '@angular/router' +import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { NgbDateStruct, NgbDropdownModule, @@ -84,6 +84,7 @@ import { ToastService } from 'src/app/services/toast.service' import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import * as UTIF from 'utif' +import { DocumentDetailFieldID } from '../admin/settings/settings.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component' @@ -124,6 +125,7 @@ enum DocumentDetailNavIDs { Notes = 5, Permissions = 6, History = 7, + Duplicates = 8, } enum ContentRenderType { @@ -181,6 +183,7 @@ export enum ZoomSetting { NgxBootstrapIconsModule, PdfViewerModule, TextAreaComponent, + RouterModule, ], }) export class DocumentDetailComponent @@ -279,6 +282,8 @@ export class DocumentDetailComponent public readonly DataType = DataType + public readonly DocumentDetailFieldID = DocumentDetailFieldID + @ViewChild('nav') nav: NgbNav @ViewChild('pdfPreview') set pdfPreview(element) { // this gets called when component added or removed from DOM @@ -325,6 +330,12 @@ export class DocumentDetailComponent return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL) } + isFieldHidden(fieldId: DocumentDetailFieldID): boolean { + return this.settings + .get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS) + .includes(fieldId) + } + private getRenderType(mimeType: string): ContentRenderType { if (!mimeType) return ContentRenderType.Unknown if (mimeType === 'application/pdf') { @@ -454,6 +465,11 @@ export class DocumentDetailComponent const openDocument = this.openDocumentService.getOpenDocument( this.documentId ) + // update duplicate documents if present + if (openDocument && doc?.duplicate_documents) { + openDocument.duplicate_documents = doc.duplicate_documents + this.openDocumentService.save() + } const useDoc = openDocument || doc if (openDocument) { if ( @@ -704,6 +720,13 @@ export class DocumentDetailComponent } this.title = this.documentTitlePipe.transform(doc.title) this.prepareForm(doc) + + if ( + this.activeNavID === DocumentDetailNavIDs.Duplicates && + !doc?.duplicate_documents?.length + ) { + this.activeNavID = DocumentDetailNavIDs.Details + } } get customFieldFormFields(): FormArray { diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html index 2323929d1..6f3a84eee 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -96,14 +96,36 @@ - @if (emailEnabled) { - - }
    +
    + +
    + + + + @if (emailEnabled) { + + } +
    +
    - - - +
    + + + +
    +
    + +
    +
    + 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-ui/src/app/data/share-link-bundle.ts b/src-ui/src/app/data/share-link-bundle.ts new file mode 100644 index 000000000..fe6134997 --- /dev/null +++ b/src-ui/src/app/data/share-link-bundle.ts @@ -0,0 +1,53 @@ +import { FileVersion } from './share-link' + +export enum ShareLinkBundleStatus { + Pending = 'pending', + Processing = 'processing', + Ready = 'ready', + Failed = 'failed', +} + +export type ShareLinkBundleError = { + bundle_id: number + message?: string + exception_type?: string + timestamp?: string +} + +export interface ShareLinkBundleSummary { + id: number + slug: string + created: string // Date + expiration?: string // Date + documents: number[] + document_count: number + file_version: FileVersion + status: ShareLinkBundleStatus + built_at?: string + size_bytes?: number + last_error?: ShareLinkBundleError +} + +export interface ShareLinkBundleCreatePayload { + document_ids: number[] + file_version: FileVersion + expiration_days: number | null +} + +export const SHARE_LINK_BUNDLE_STATUS_LABELS: Record< + ShareLinkBundleStatus, + string +> = { + [ShareLinkBundleStatus.Pending]: $localize`Pending`, + [ShareLinkBundleStatus.Processing]: $localize`Processing`, + [ShareLinkBundleStatus.Ready]: $localize`Ready`, + [ShareLinkBundleStatus.Failed]: $localize`Failed`, +} + +export const SHARE_LINK_BUNDLE_FILE_VERSION_LABELS: Record< + FileVersion, + string +> = { + [FileVersion.Archive]: $localize`Archive`, + [FileVersion.Original]: $localize`Original`, +} diff --git a/src-ui/src/app/data/share-link.ts b/src-ui/src/app/data/share-link.ts index debc8c111..d9710bd47 100644 --- a/src-ui/src/app/data/share-link.ts +++ b/src-ui/src/app/data/share-link.ts @@ -5,6 +5,18 @@ export enum FileVersion { Original = 'original', } +export interface ShareLinkExpirationOption { + label: string + value: number | null +} + +export const SHARE_LINK_EXPIRATION_OPTIONS: ShareLinkExpirationOption[] = [ + { label: $localize`1 day`, value: 1 }, + { label: $localize`7 days`, value: 7 }, + { label: $localize`30 days`, value: 30 }, + { label: $localize`Never`, value: null }, +] + export interface ShareLink extends ObjectWithPermissions { created: string // Date diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index e797fe9b3..827a1b82d 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -70,6 +70,8 @@ export const SETTINGS_KEYS = { 'general-settings:document-editing:remove-inbox-tags', DOCUMENT_EDITING_OVERLAY_THUMBNAIL: 'general-settings:document-editing:overlay-thumbnail', + DOCUMENT_DETAILS_HIDDEN_FIELDS: + 'general-settings:document-details:hidden-fields', SEARCH_DB_ONLY: 'general-settings:search:db-only', SEARCH_FULL_TYPE: 'general-settings:search:more-link', EMPTY_TRASH_DELAY: 'trash_delay', @@ -255,6 +257,11 @@ export const SETTINGS: UiSetting[] = [ type: 'boolean', default: true, }, + { + key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, + type: 'array', + default: [], + }, { key: SETTINGS_KEYS.SEARCH_DB_ONLY, type: 'boolean', diff --git a/src-ui/src/app/data/workflow-trigger.ts b/src-ui/src/app/data/workflow-trigger.ts index 888b18cc3..2bc89f188 100644 --- a/src-ui/src/app/data/workflow-trigger.ts +++ b/src-ui/src/app/data/workflow-trigger.ts @@ -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 diff --git a/src-ui/src/app/services/rest/share-link-bundle.service.spec.ts b/src-ui/src/app/services/rest/share-link-bundle.service.spec.ts new file mode 100644 index 000000000..6b87ddf04 --- /dev/null +++ b/src-ui/src/app/services/rest/share-link-bundle.service.spec.ts @@ -0,0 +1,60 @@ +import { HttpTestingController } from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { Subscription } from 'rxjs' +import { environment } from 'src/environments/environment' +import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec' +import { ShareLinkBundleService } from './share-link-bundle.service' + +const endpoint = 'share_link_bundles' + +commonAbstractPaperlessServiceTests(endpoint, ShareLinkBundleService) + +describe('ShareLinkBundleService', () => { + let httpTestingController: HttpTestingController + let service: ShareLinkBundleService + let subscription: Subscription | undefined + + beforeEach(() => { + httpTestingController = TestBed.inject(HttpTestingController) + service = TestBed.inject(ShareLinkBundleService) + }) + + afterEach(() => { + subscription?.unsubscribe() + httpTestingController.verify() + }) + + it('creates bundled share links', () => { + const payload = { + document_ids: [1, 2], + file_version: 'archive', + expiration_days: 7, + } + subscription = service.createBundle(payload as any).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/` + ) + expect(req.request.method).toBe('POST') + expect(req.request.body).toEqual(payload) + req.flush({}) + }) + + it('rebuilds bundles', () => { + subscription = service.rebuildBundle(12).subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/12/rebuild/` + ) + expect(req.request.method).toBe('POST') + expect(req.request.body).toEqual({}) + req.flush({}) + }) + + it('lists bundles with expected parameters', () => { + subscription = service.listAllBundles().subscribe() + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created` + ) + expect(req.request.method).toBe('GET') + req.flush({ results: [] }) + }) +}) diff --git a/src-ui/src/app/services/rest/share-link-bundle.service.ts b/src-ui/src/app/services/rest/share-link-bundle.service.ts new file mode 100644 index 000000000..2aa719974 --- /dev/null +++ b/src-ui/src/app/services/rest/share-link-bundle.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { map } from 'rxjs/operators' +import { + ShareLinkBundleCreatePayload, + ShareLinkBundleSummary, +} from 'src/app/data/share-link-bundle' +import { AbstractNameFilterService } from './abstract-name-filter-service' + +@Injectable({ + providedIn: 'root', +}) +export class ShareLinkBundleService extends AbstractNameFilterService { + constructor() { + super() + this.resourceName = 'share_link_bundles' + } + + createBundle( + payload: ShareLinkBundleCreatePayload + ): Observable { + this.clearCache() + return this.http.post( + this.getResourceUrl(), + payload + ) + } + rebuildBundle(bundleId: number): Observable { + this.clearCache() + return this.http.post( + this.getResourceUrl(bundleId, 'rebuild'), + {} + ) + } + + listAllBundles(): Observable { + return this.list(1, 1000, 'created', true).pipe( + map((response) => response.results) + ) + } +} diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index b85d8ff35..f6f50a288 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -1,7 +1,7 @@ import { - APP_INITIALIZER, - enableProdMode, importProvidersFrom, + inject, + provideAppInitializer, provideZoneChangeDetection, } from '@angular/core' @@ -159,7 +159,6 @@ import { UsernamePipe } from './app/pipes/username.pipe' import { SettingsService } from './app/services/settings.service' import { LocalizedDateParserFormatter } from './app/utils/ngb-date-parser-formatter' import { ISODateAdapter } from './app/utils/ngb-iso-date-adapter' -import { environment } from './environments/environment' import localeAf from '@angular/common/locales/af' import localeAr from '@angular/common/locales/ar' @@ -237,11 +236,11 @@ registerLocaleData(localeUk) registerLocaleData(localeZh) registerLocaleData(localeZhHant) -function initializeApp(settings: SettingsService) { - return () => { - return settings.initializeSettings() - } +function initializeApp() { + const settings = inject(SettingsService) + return settings.initializeSettings() } + const icons = { airplane, archive, @@ -363,10 +362,6 @@ const icons = { xLg, } -if (environment.production) { - enableProdMode() -} - bootstrapApplication(AppComponent, { providers: [ provideZoneChangeDetection(), @@ -383,12 +378,7 @@ bootstrapApplication(AppComponent, { DragDropModule, NgxBootstrapIconsModule.pick(icons) ), - { - provide: APP_INITIALIZER, - useFactory: initializeApp, - deps: [SettingsService], - multi: true, - }, + provideAppInitializer(initializeApp), DatePipe, CookieService, { diff --git a/src/documents/admin.py b/src/documents/admin.py index 1ebbdc9ce..6c7a6f304 100644 --- a/src/documents/admin.py +++ b/src/documents/admin.py @@ -13,6 +13,7 @@ from documents.models import PaperlessTask from documents.models import SavedView from documents.models import SavedViewFilterRule from documents.models import ShareLink +from documents.models import ShareLinkBundle from documents.models import StoragePath from documents.models import Tag from documents.tasks import update_document_parent_tags @@ -184,6 +185,22 @@ class ShareLinksAdmin(GuardedModelAdmin): return super().get_queryset(request).select_related("document__correspondent") +class ShareLinkBundleAdmin(GuardedModelAdmin): + list_display = ("created", "status", "expiration", "owner", "slug") + list_filter = ("status", "created", "expiration", "owner") + search_fields = ("slug",) + + def get_queryset(self, request): # pragma: no cover + return ( + super() + .get_queryset(request) + .select_related("owner") + .prefetch_related( + "documents", + ) + ) + + class CustomFieldsAdmin(GuardedModelAdmin): fields = ("name", "created", "data_type") readonly_fields = ("created", "data_type") @@ -215,6 +232,7 @@ admin.site.register(StoragePath, StoragePathAdmin) admin.site.register(PaperlessTask, TaskAdmin) admin.site.register(Note, NotesAdmin) admin.site.register(ShareLink, ShareLinksAdmin) +admin.site.register(ShareLinkBundle, ShareLinkBundleAdmin) admin.site.register(CustomField, CustomFieldsAdmin) admin.site.register(CustomFieldInstance, CustomFieldInstancesAdmin) diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 91d36a3a4..6c3ff5374 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/data_models.py b/src/documents/data_models.py index 661f946e0..ee16cce9e 100644 --- a/src/documents/data_models.py +++ b/src/documents/data_models.py @@ -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() } diff --git a/src/documents/filters.py b/src/documents/filters.py index 54f3c1fa0..9e53d01af 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -39,6 +39,7 @@ from documents.models import Document from documents.models import DocumentType from documents.models import PaperlessTask from documents.models import ShareLink +from documents.models import ShareLinkBundle from documents.models import StoragePath from documents.models import Tag @@ -796,6 +797,29 @@ class ShareLinkFilterSet(FilterSet): } +class ShareLinkBundleFilterSet(FilterSet): + documents = Filter(method="filter_documents") + + class Meta: + model = ShareLinkBundle + fields = { + "created": DATETIME_KWARGS, + "expiration": DATETIME_KWARGS, + "status": ["exact"], + } + + def filter_documents(self, queryset, name, value): + ids = [] + if value: + try: + ids = [int(item) for item in value.split(",") if item] + except ValueError: + return queryset.none() + if not ids: + return queryset + return queryset.filter(documents__in=ids).distinct() + + class PaperlessTaskFilterSet(FilterSet): acknowledged = BooleanFilter( label="Acknowledged", 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/matching.py b/src/documents/matching.py index 198ead64c..9276ad583 100644 --- a/src/documents/matching.py +++ b/src/documents/matching.py @@ -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", ) ) diff --git a/src/documents/migrations/0005_workflowtrigger_filter_has_any_correspondents_and_more.py b/src/documents/migrations/0005_workflowtrigger_filter_has_any_correspondents_and_more.py new file mode 100644 index 000000000..db5ef5754 --- /dev/null +++ b/src/documents/migrations/0005_workflowtrigger_filter_has_any_correspondents_and_more.py @@ -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", + ), + ), + ] 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/migrations/0008_sharelinkbundle.py b/src/documents/migrations/0008_sharelinkbundle.py new file mode 100644 index 000000000..35ef64c75 --- /dev/null +++ b/src/documents/migrations/0008_sharelinkbundle.py @@ -0,0 +1,177 @@ +# Generated by Django 5.2.9 on 2026-01-27 01:09 + +import django.db.models.deletion +import django.db.models.functions.text +import django.utils.timezone +from django.conf import settings +from django.contrib.auth.management import create_permissions +from django.contrib.auth.models import Group +from django.contrib.auth.models import Permission +from django.contrib.auth.models import User +from django.db import migrations +from django.db import models + + +def grant_share_link_bundle_permissions(apps, schema_editor): + # Ensure newly introduced permissions are created for all apps + for app_config in apps.get_app_configs(): + app_config.models_module = True + create_permissions(app_config, apps=apps, verbosity=0) + app_config.models_module = None + + add_document_perm = Permission.objects.filter(codename="add_document").first() + share_bundle_permissions = Permission.objects.filter( + codename__contains="sharelinkbundle", + ) + + users = User.objects.filter(user_permissions=add_document_perm).distinct() + for user in users: + user.user_permissions.add(*share_bundle_permissions) + + groups = Group.objects.filter(permissions=add_document_perm).distinct() + for group in groups: + group.permissions.add(*share_bundle_permissions) + + +def revoke_share_link_bundle_permissions(apps, schema_editor): + share_bundle_permissions = Permission.objects.filter( + codename__contains="sharelinkbundle", + ) + for user in User.objects.all(): + user.user_permissions.remove(*share_bundle_permissions) + for group in Group.objects.all(): + group.permissions.remove(*share_bundle_permissions) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("documents", "0007_document_content_length"), + ] + + operations = [ + migrations.CreateModel( + name="ShareLinkBundle", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + models.DateTimeField( + blank=True, + db_index=True, + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "expiration", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="expiration", + ), + ), + ( + "slug", + models.SlugField( + blank=True, + editable=False, + unique=True, + verbose_name="slug", + ), + ), + ( + "file_version", + models.CharField( + choices=[("archive", "Archive"), ("original", "Original")], + default="archive", + max_length=50, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("processing", "Processing"), + ("ready", "Ready"), + ("failed", "Failed"), + ], + default="pending", + max_length=50, + ), + ), + ( + "size_bytes", + models.PositiveIntegerField( + blank=True, + null=True, + verbose_name="size (bytes)", + ), + ), + ( + "last_error", + models.JSONField( + blank=True, + null=True, + default=None, + verbose_name="last error", + ), + ), + ( + "file_path", + models.CharField( + blank=True, + max_length=512, + verbose_name="file path", + ), + ), + ( + "built_at", + models.DateTimeField( + blank=True, + null=True, + verbose_name="built at", + ), + ), + ( + "documents", + models.ManyToManyField( + related_name="share_link_bundles", + to="documents.document", + verbose_name="documents", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="share_link_bundles", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "ordering": ("-created",), + "verbose_name": "share link bundle", + "verbose_name_plural": "share link bundles", + }, + ), + migrations.RunPython( + grant_share_link_bundle_permissions, + reverse_code=revoke_share_link_bundle_permissions, + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 88d33f1fe..72470ef6e 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."), ) @@ -756,6 +766,114 @@ class ShareLink(SoftDeleteModel): return f"Share Link for {self.document.title}" +class ShareLinkBundle(models.Model): + class Status(models.TextChoices): + PENDING = ("pending", _("Pending")) + PROCESSING = ("processing", _("Processing")) + READY = ("ready", _("Ready")) + FAILED = ("failed", _("Failed")) + + created = models.DateTimeField( + _("created"), + default=timezone.now, + db_index=True, + blank=True, + editable=False, + ) + + expiration = models.DateTimeField( + _("expiration"), + blank=True, + null=True, + db_index=True, + ) + + slug = models.SlugField( + _("slug"), + db_index=True, + unique=True, + blank=True, + editable=False, + ) + + owner = models.ForeignKey( + User, + blank=True, + null=True, + related_name="share_link_bundles", + on_delete=models.SET_NULL, + verbose_name=_("owner"), + ) + + file_version = models.CharField( + max_length=50, + choices=ShareLink.FileVersion.choices, + default=ShareLink.FileVersion.ARCHIVE, + ) + + status = models.CharField( + max_length=50, + choices=Status.choices, + default=Status.PENDING, + ) + + size_bytes = models.PositiveIntegerField( + _("size (bytes)"), + blank=True, + null=True, + ) + + last_error = models.JSONField( + _("last error"), + blank=True, + null=True, + default=None, + ) + + file_path = models.CharField( + _("file path"), + max_length=512, + blank=True, + ) + + built_at = models.DateTimeField( + _("built at"), + null=True, + blank=True, + ) + + documents = models.ManyToManyField( + "documents.Document", + related_name="share_link_bundles", + verbose_name=_("documents"), + ) + + class Meta: + ordering = ("-created",) + verbose_name = _("share link bundle") + verbose_name_plural = _("share link bundles") + + def __str__(self): + return _("Share link bundle %(slug)s") % {"slug": self.slug} + + @property + def absolute_file_path(self) -> Path | None: + if not self.file_path: + return None + return (settings.SHARE_LINK_BUNDLE_DIR / Path(self.file_path)).resolve() + + def remove_file(self): + if self.absolute_file_path is not None and self.absolute_file_path.exists(): + try: + self.absolute_file_path.unlink() + except OSError: + pass + + def delete(self, using=None, *, keep_parents=False): + self.remove_file() + return super().delete(using=using, keep_parents=keep_parents) + + class CustomField(models.Model): """ Defines the name and type of a custom field @@ -946,7 +1064,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) @@ -1066,6 +1184,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 +1213,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 +1228,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, 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 a265b036b..de5f4d33f 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -4,6 +4,7 @@ import logging import math import re from datetime import datetime +from datetime import timedelta from decimal import Decimal from typing import TYPE_CHECKING from typing import Literal @@ -23,7 +24,9 @@ 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 import timezone from django.utils.crypto import get_random_string from django.utils.dateparse import parse_datetime from django.utils.text import slugify @@ -61,6 +64,7 @@ from documents.models import PaperlessTask from documents.models import SavedView from documents.models import SavedViewFilterRule from documents.models import ShareLink +from documents.models import ShareLinkBundle from documents.models import StoragePath from documents.models import Tag from documents.models import UiSettings @@ -72,6 +76,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 +87,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 +1022,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 +1065,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 +1091,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 +1278,7 @@ class DocumentSerializer( "archive_serial_number", "original_file_name", "archived_file_name", + "duplicate_documents", "owner", "permissions", "user_can_change", @@ -2094,10 +2140,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 +2170,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( @@ -2172,6 +2231,104 @@ class ShareLinkSerializer(OwnedObjectSerializer): return super().create(validated_data) +class ShareLinkBundleSerializer(OwnedObjectSerializer): + document_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + allow_empty=False, + write_only=True, + ) + expiration_days = serializers.IntegerField( + required=False, + allow_null=True, + min_value=1, + write_only=True, + ) + documents = serializers.PrimaryKeyRelatedField( + many=True, + read_only=True, + ) + document_count = SerializerMethodField() + + class Meta: + model = ShareLinkBundle + fields = ( + "id", + "created", + "expiration", + "expiration_days", + "slug", + "file_version", + "status", + "size_bytes", + "last_error", + "built_at", + "documents", + "document_ids", + "document_count", + ) + read_only_fields = ( + "id", + "created", + "expiration", + "slug", + "status", + "size_bytes", + "last_error", + "built_at", + "documents", + "document_count", + ) + + def validate_document_ids(self, value): + unique_ids = set(value) + if len(unique_ids) != len(value): + raise serializers.ValidationError( + _("Duplicate document identifiers are not allowed."), + ) + return value + + def create(self, validated_data): + document_ids = validated_data.pop("document_ids") + expiration_days = validated_data.pop("expiration_days", None) + validated_data["slug"] = get_random_string(50) + if expiration_days: + validated_data["expiration"] = timezone.now() + timedelta( + days=expiration_days, + ) + else: + validated_data["expiration"] = None + + share_link_bundle = super().create(validated_data) + + documents = list( + Document.objects.filter(pk__in=document_ids).only( + "pk", + ), + ) + documents_by_id = {doc.pk: doc for doc in documents} + missing = [ + str(doc_id) for doc_id in document_ids if doc_id not in documents_by_id + ] + if missing: + raise serializers.ValidationError( + { + "document_ids": _( + "Documents not found: %(ids)s", + ) + % {"ids": ", ".join(missing)}, + }, + ) + + ordered_documents = [documents_by_id[doc_id] for doc_id in document_ids] + share_link_bundle.documents.set(ordered_documents) + share_link_bundle.document_total = len(ordered_documents) + + return share_link_bundle + + def get_document_count(self, obj: ShareLinkBundle) -> int: + return getattr(obj, "document_total") or obj.documents.count() + + class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin): objects = serializers.ListField( required=True, @@ -2299,8 +2456,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 +2698,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 +2734,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, diff --git a/src/documents/tasks.py b/src/documents/tasks.py index fed8a65f7..fc8911705 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -3,8 +3,10 @@ import hashlib import logging import shutil import uuid +import zipfile from pathlib import Path from tempfile import TemporaryDirectory +from tempfile import mkstemp import tqdm from celery import Task @@ -22,6 +24,8 @@ from whoosh.writing import AsyncWriter from documents import index from documents import sanity_checker from documents.barcodes import BarcodePlugin +from documents.bulk_download import ArchiveOnlyStrategy +from documents.bulk_download import OriginalsOnlyStrategy from documents.caching import clear_document_caches from documents.classifier import DocumentClassifier from documents.classifier import load_classifier @@ -39,6 +43,8 @@ from documents.models import CustomFieldInstance from documents.models import Document from documents.models import DocumentType from documents.models import PaperlessTask +from documents.models import ShareLink +from documents.models import ShareLinkBundle from documents.models import StoragePath from documents.models import Tag from documents.models import WorkflowRun @@ -625,3 +631,117 @@ def update_document_in_llm_index(document): @shared_task def remove_document_from_llm_index(document): llm_index_remove_document(document) + + +@shared_task +def build_share_link_bundle(bundle_id: int): + try: + bundle = ( + ShareLinkBundle.objects.filter(pk=bundle_id) + .prefetch_related("documents") + .get() + ) + except ShareLinkBundle.DoesNotExist: + logger.warning("Share link bundle %s no longer exists.", bundle_id) + return + + bundle.remove_file() + bundle.status = ShareLinkBundle.Status.PROCESSING + bundle.last_error = None + bundle.size_bytes = None + bundle.built_at = None + bundle.file_path = "" + bundle.save( + update_fields=[ + "status", + "last_error", + "size_bytes", + "built_at", + "file_path", + ], + ) + + documents = list(bundle.documents.all().order_by("pk")) + + _, temp_zip_path_str = mkstemp(suffix=".zip", dir=settings.SCRATCH_DIR) + temp_zip_path = Path(temp_zip_path_str) + + try: + strategy_class = ( + ArchiveOnlyStrategy + if bundle.file_version == ShareLink.FileVersion.ARCHIVE + else OriginalsOnlyStrategy + ) + with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: + strategy = strategy_class(zipf) + for document in documents: + strategy.add_document(document) + + output_dir = settings.SHARE_LINK_BUNDLE_DIR + output_dir.mkdir(parents=True, exist_ok=True) + final_path = (output_dir / f"{bundle.slug}.zip").resolve() + if final_path.exists(): + final_path.unlink() + shutil.move(temp_zip_path, final_path) + + bundle.file_path = f"{bundle.slug}.zip" + bundle.size_bytes = final_path.stat().st_size + bundle.status = ShareLinkBundle.Status.READY + bundle.built_at = timezone.now() + bundle.last_error = None + bundle.save( + update_fields=[ + "file_path", + "size_bytes", + "status", + "built_at", + "last_error", + ], + ) + logger.info("Built share link bundle %s", bundle.pk) + except Exception as exc: + logger.exception( + "Failed to build share link bundle %s: %s", + bundle_id, + exc, + ) + bundle.status = ShareLinkBundle.Status.FAILED + bundle.last_error = { + "bundle_id": bundle_id, + "exception_type": exc.__class__.__name__, + "message": str(exc), + "timestamp": timezone.now().isoformat(), + } + bundle.save(update_fields=["status", "last_error"]) + try: + temp_zip_path.unlink() + except OSError: + pass + raise + finally: + try: + temp_zip_path.unlink(missing_ok=True) + except OSError: + pass + + +@shared_task +def cleanup_expired_share_link_bundles(): + now = timezone.now() + expired_qs = ShareLinkBundle.objects.filter( + expiration__isnull=False, + expiration__lt=now, + ) + count = 0 + for bundle in expired_qs.iterator(): + count += 1 + try: + bundle.delete() + except Exception as exc: + logger.warning( + "Failed to delete expired share link bundle %s: %s", + bundle.pk, + exc, + ) + if count: + logger.info("Deleted %s expired share link bundle(s)", count) 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_api_workflows.py b/src/documents/tests/test_api_workflows.py index 9efdb8451..1d3efd457 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -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, diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index 325279898..be22f3354 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -478,21 +478,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: @@ -506,9 +506,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( @@ -711,12 +712,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): @@ -736,15 +770,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_double_sided.py b/src/documents/tests/test_double_sided.py index 5d068b735..32ca5ceab 100644 --- a/src/documents/tests/test_double_sided.py +++ b/src/documents/tests/test_double_sided.py @@ -224,17 +224,18 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase): THEN: - The collated file gets put into foo/bar """ + # TODO: parameterize this instead for path in [ Path("foo") / "bar" / "double-sided", Path("double-sided") / "foo" / "bar", ]: - with self.subTest(path=path): + with self.subTest(path=str(path)): # Ensure we get fresh directories for each run self.tearDown() self.setUp() self.create_staging_file() - self.consume_file("double-sided-odd.pdf", path / "foo.pdf") + self.consume_file("double-sided-odd.pdf", Path(path) / "foo.pdf") self.assertIsFile( self.dirs.consumption_dir / "foo" / "bar" / "foo-collated.pdf", ) 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/tests/test_migration_share_link_bundle.py b/src/documents/tests/test_migration_share_link_bundle.py new file mode 100644 index 000000000..74aeca14c --- /dev/null +++ b/src/documents/tests/test_migration_share_link_bundle.py @@ -0,0 +1,51 @@ +from documents.tests.utils import TestMigrations + + +class TestMigrateShareLinkBundlePermissions(TestMigrations): + migrate_from = "0007_document_content_length" + migrate_to = "0008_sharelinkbundle" + + def setUpBeforeMigration(self, apps): + User = apps.get_model("auth", "User") + Group = apps.get_model("auth", "Group") + self.Permission = apps.get_model("auth", "Permission") + self.user = User.objects.create(username="user1") + self.group = Group.objects.create(name="group1") + add_document = self.Permission.objects.get(codename="add_document") + self.user.user_permissions.add(add_document.id) + self.group.permissions.add(add_document.id) + + def test_share_link_permissions_granted_to_add_document_holders(self): + share_perms = self.Permission.objects.filter( + codename__contains="sharelinkbundle", + ) + self.assertTrue(self.user.user_permissions.filter(pk__in=share_perms).exists()) + self.assertTrue(self.group.permissions.filter(pk__in=share_perms).exists()) + + +class TestReverseMigrateShareLinkBundlePermissions(TestMigrations): + migrate_from = "0008_sharelinkbundle" + migrate_to = "0007_document_content_length" + + def setUpBeforeMigration(self, apps): + User = apps.get_model("auth", "User") + Group = apps.get_model("auth", "Group") + self.Permission = apps.get_model("auth", "Permission") + self.user = User.objects.create(username="user1") + self.group = Group.objects.create(name="group1") + add_document = self.Permission.objects.get(codename="add_document") + share_perms = self.Permission.objects.filter( + codename__contains="sharelinkbundle", + ) + self.share_perm_ids = list(share_perms.values_list("id", flat=True)) + + self.user.user_permissions.add(add_document.id, *self.share_perm_ids) + self.group.permissions.add(add_document.id, *self.share_perm_ids) + + def test_share_link_permissions_revoked_on_reverse(self): + self.assertFalse( + self.user.user_permissions.filter(pk__in=self.share_perm_ids).exists(), + ) + self.assertFalse( + self.group.permissions.filter(pk__in=self.share_perm_ids).exists(), + ) diff --git a/src/documents/tests/test_share_link_bundles.py b/src/documents/tests/test_share_link_bundles.py new file mode 100644 index 000000000..f7deed5c9 --- /dev/null +++ b/src/documents/tests/test_share_link_bundles.py @@ -0,0 +1,536 @@ +from __future__ import annotations + +import zipfile +from datetime import timedelta +from pathlib import Path +from unittest import mock + +from django.conf import settings +from django.contrib.auth.models import User +from django.utils import timezone +from rest_framework import serializers +from rest_framework import status +from rest_framework.test import APITestCase + +from documents.filters import ShareLinkBundleFilterSet +from documents.models import ShareLink +from documents.models import ShareLinkBundle +from documents.serialisers import ShareLinkBundleSerializer +from documents.tasks import build_share_link_bundle +from documents.tasks import cleanup_expired_share_link_bundles +from documents.tests.factories import DocumentFactory +from documents.tests.utils import DirectoriesMixin + + +class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase): + ENDPOINT = "/api/share_link_bundles/" + + def setUp(self): + super().setUp() + self.user = User.objects.create_superuser(username="bundle_admin") + self.client.force_authenticate(self.user) + self.document = DocumentFactory.create() + + @mock.patch("documents.views.build_share_link_bundle.delay") + def test_create_bundle_triggers_build_job(self, delay_mock): + payload = { + "document_ids": [self.document.pk], + "file_version": ShareLink.FileVersion.ARCHIVE, + "expiration_days": 7, + } + + response = self.client.post(self.ENDPOINT, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + bundle = ShareLinkBundle.objects.get(pk=response.data["id"]) + self.assertEqual(bundle.documents.count(), 1) + self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING) + delay_mock.assert_called_once_with(bundle.pk) + + def test_create_bundle_rejects_missing_documents(self): + payload = { + "document_ids": [9999], + "file_version": ShareLink.FileVersion.ARCHIVE, + "expiration_days": 7, + } + + response = self.client.post(self.ENDPOINT, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("document_ids", response.data) + + @mock.patch("documents.views.has_perms_owner_aware", return_value=False) + def test_create_bundle_rejects_insufficient_permissions(self, perms_mock): + payload = { + "document_ids": [self.document.pk], + "file_version": ShareLink.FileVersion.ARCHIVE, + "expiration_days": 7, + } + + response = self.client.post(self.ENDPOINT, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("document_ids", response.data) + perms_mock.assert_called() + + @mock.patch("documents.views.build_share_link_bundle.delay") + def test_rebuild_bundle_resets_state(self, delay_mock): + bundle = ShareLinkBundle.objects.create( + slug="rebuild-slug", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.FAILED, + ) + bundle.documents.set([self.document]) + bundle.last_error = {"message": "Something went wrong"} + bundle.size_bytes = 100 + bundle.file_path = "path/to/file.zip" + bundle.save() + + response = self.client.post(f"{self.ENDPOINT}{bundle.pk}/rebuild/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + bundle.refresh_from_db() + self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING) + self.assertIsNone(bundle.last_error) + self.assertIsNone(bundle.size_bytes) + self.assertEqual(bundle.file_path, "") + delay_mock.assert_called_once_with(bundle.pk) + + def test_rebuild_bundle_rejects_processing_status(self): + bundle = ShareLinkBundle.objects.create( + slug="processing-slug", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.PROCESSING, + ) + bundle.documents.set([self.document]) + + response = self.client.post(f"{self.ENDPOINT}{bundle.pk}/rebuild/") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("detail", response.data) + + def test_create_bundle_rejects_duplicate_documents(self): + payload = { + "document_ids": [self.document.pk, self.document.pk], + "file_version": ShareLink.FileVersion.ARCHIVE, + "expiration_days": 7, + } + + response = self.client.post(self.ENDPOINT, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("document_ids", response.data) + + def test_download_ready_bundle_streams_file(self): + bundle_file = Path(self.dirs.media_dir) / "bundles" / "ready.zip" + bundle_file.parent.mkdir(parents=True, exist_ok=True) + bundle_file.write_bytes(b"binary-zip-content") + + bundle = ShareLinkBundle.objects.create( + slug="readyslug", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.READY, + file_path=str(bundle_file), + ) + bundle.documents.set([self.document]) + + self.client.logout() + response = self.client.get(f"/share/{bundle.slug}/") + content = b"".join(response.streaming_content) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response["Content-Type"], "application/zip") + self.assertEqual(content, b"binary-zip-content") + self.assertIn("attachment;", response["Content-Disposition"]) + + def test_download_pending_bundle_returns_202(self): + bundle = ShareLinkBundle.objects.create( + slug="pendingslug", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.PENDING, + ) + bundle.documents.set([self.document]) + + self.client.logout() + response = self.client.get(f"/share/{bundle.slug}/") + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + def test_download_failed_bundle_returns_503(self): + bundle = ShareLinkBundle.objects.create( + slug="failedslug", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.FAILED, + ) + bundle.documents.set([self.document]) + + self.client.logout() + response = self.client.get(f"/share/{bundle.slug}/") + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + + def test_expired_share_link_redirects(self): + share_link = ShareLink.objects.create( + slug="expiredlink", + document=self.document, + file_version=ShareLink.FileVersion.ORIGINAL, + expiration=timezone.now() - timedelta(hours=1), + ) + + self.client.logout() + response = self.client.get(f"/share/{share_link.slug}/") + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertIn("sharelink_expired=1", response["Location"]) + + def test_unknown_share_link_redirects(self): + self.client.logout() + response = self.client.get("/share/unknownsharelink/") + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertIn("sharelink_notfound=1", response["Location"]) + + +class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase): + def setUp(self): + super().setUp() + self.document = DocumentFactory.create() + + def test_cleanup_expired_share_link_bundles(self): + expired_path = Path(self.dirs.media_dir) / "expired.zip" + expired_path.parent.mkdir(parents=True, exist_ok=True) + expired_path.write_bytes(b"expired") + + active_path = Path(self.dirs.media_dir) / "active.zip" + active_path.write_bytes(b"active") + + expired_bundle = ShareLinkBundle.objects.create( + slug="expired-bundle", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.READY, + expiration=timezone.now() - timedelta(days=1), + file_path=str(expired_path), + ) + expired_bundle.documents.set([self.document]) + + active_bundle = ShareLinkBundle.objects.create( + slug="active-bundle", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.READY, + expiration=timezone.now() + timedelta(days=1), + file_path=str(active_path), + ) + active_bundle.documents.set([self.document]) + + cleanup_expired_share_link_bundles() + + self.assertFalse(ShareLinkBundle.objects.filter(pk=expired_bundle.pk).exists()) + self.assertTrue(ShareLinkBundle.objects.filter(pk=active_bundle.pk).exists()) + self.assertFalse(expired_path.exists()) + self.assertTrue(active_path.exists()) + + def test_cleanup_expired_share_link_bundles_logs_on_failure(self): + expired_bundle = ShareLinkBundle.objects.create( + slug="expired-bundle", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.READY, + expiration=timezone.now() - timedelta(days=1), + ) + expired_bundle.documents.set([self.document]) + + with mock.patch.object( + ShareLinkBundle, + "delete", + side_effect=RuntimeError("fail"), + ): + with self.assertLogs("paperless.tasks", level="WARNING") as logs: + cleanup_expired_share_link_bundles() + + self.assertTrue( + any( + "Failed to delete expired share link bundle" in msg + for msg in logs.output + ), + ) + + +class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase): + def setUp(self): + super().setUp() + self.document = DocumentFactory.create( + mime_type="application/pdf", + checksum="123", + ) + self.document.archive_checksum = "" + self.document.save() + self.addCleanup( + setattr, + settings, + "SHARE_LINK_BUNDLE_DIR", + settings.SHARE_LINK_BUNDLE_DIR, + ) + settings.SHARE_LINK_BUNDLE_DIR = ( + Path(settings.MEDIA_ROOT) / "documents" / "share_link_bundles" + ) + + def _write_document_file(self, *, archive: bool, content: bytes) -> Path: + if archive: + self.document.archive_filename = f"{self.document.pk:07}.pdf" + self.document.save() + path = self.document.archive_path + else: + path = self.document.source_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return path + + def test_build_share_link_bundle_creates_zip_and_sets_metadata(self): + self._write_document_file(archive=False, content=b"source") + archive_path = self._write_document_file(archive=True, content=b"archive") + bundle = ShareLinkBundle.objects.create( + slug="build-archive", + file_version=ShareLink.FileVersion.ARCHIVE, + ) + bundle.documents.set([self.document]) + + build_share_link_bundle(bundle.pk) + + bundle.refresh_from_db() + self.assertEqual(bundle.status, ShareLinkBundle.Status.READY) + self.assertIsNone(bundle.last_error) + self.assertIsNotNone(bundle.built_at) + self.assertGreater(bundle.size_bytes or 0, 0) + final_path = bundle.absolute_file_path + self.assertIsNotNone(final_path) + self.assertTrue(final_path.exists()) + with zipfile.ZipFile(final_path) as zipf: + names = zipf.namelist() + self.assertEqual(len(names), 1) + self.assertEqual(zipf.read(names[0]), archive_path.read_bytes()) + + def test_build_share_link_bundle_overwrites_existing_file(self): + self._write_document_file(archive=False, content=b"source") + bundle = ShareLinkBundle.objects.create( + slug="overwrite", + file_version=ShareLink.FileVersion.ORIGINAL, + ) + bundle.documents.set([self.document]) + + existing = settings.SHARE_LINK_BUNDLE_DIR / "overwrite.zip" + existing.parent.mkdir(parents=True, exist_ok=True) + existing.write_bytes(b"old") + + build_share_link_bundle(bundle.pk) + + bundle.refresh_from_db() + final_path = bundle.absolute_file_path + self.assertIsNotNone(final_path) + self.assertTrue(final_path.exists()) + self.assertNotEqual(final_path.read_bytes(), b"old") + + def test_build_share_link_bundle_failure_marks_failed(self): + self._write_document_file(archive=False, content=b"source") + bundle = ShareLinkBundle.objects.create( + slug="fail-bundle", + file_version=ShareLink.FileVersion.ORIGINAL, + ) + bundle.documents.set([self.document]) + + with ( + mock.patch( + "documents.tasks.OriginalsOnlyStrategy.add_document", + side_effect=RuntimeError("zip failure"), + ), + mock.patch("pathlib.Path.unlink") as unlink_mock, + ): + unlink_mock.side_effect = [OSError("unlink"), OSError("unlink-finally")] + [ + None, + ] * 5 + with self.assertRaises(RuntimeError): + build_share_link_bundle(bundle.pk) + + bundle.refresh_from_db() + self.assertEqual(bundle.status, ShareLinkBundle.Status.FAILED) + self.assertIsInstance(bundle.last_error, dict) + self.assertEqual(bundle.last_error.get("message"), "zip failure") + self.assertEqual(bundle.last_error.get("exception_type"), "RuntimeError") + scratch_zips = list(Path(settings.SCRATCH_DIR).glob("*.zip")) + self.assertTrue(scratch_zips) + for path in scratch_zips: + path.unlink(missing_ok=True) + + def test_build_share_link_bundle_missing_bundle_noop(self): + # Should not raise when bundle does not exist + build_share_link_bundle(99999) + + +class ShareLinkBundleFilterSetTests(DirectoriesMixin, APITestCase): + def setUp(self): + super().setUp() + self.document = DocumentFactory.create() + self.document.checksum = "doc1checksum" + self.document.save() + self.other_document = DocumentFactory.create() + self.other_document.checksum = "doc2checksum" + self.other_document.save() + self.bundle_one = ShareLinkBundle.objects.create( + slug="bundle-one", + file_version=ShareLink.FileVersion.ORIGINAL, + ) + self.bundle_one.documents.set([self.document]) + self.bundle_two = ShareLinkBundle.objects.create( + slug="bundle-two", + file_version=ShareLink.FileVersion.ORIGINAL, + ) + self.bundle_two.documents.set([self.other_document]) + + def test_filter_documents_returns_all_for_empty_value(self): + filterset = ShareLinkBundleFilterSet( + data={"documents": ""}, + queryset=ShareLinkBundle.objects.all(), + ) + + self.assertCountEqual(filterset.qs, [self.bundle_one, self.bundle_two]) + + def test_filter_documents_handles_invalid_input(self): + filterset = ShareLinkBundleFilterSet( + data={"documents": "invalid"}, + queryset=ShareLinkBundle.objects.all(), + ) + + self.assertFalse(filterset.qs.exists()) + + def test_filter_documents_filters_by_multiple_ids(self): + filterset = ShareLinkBundleFilterSet( + data={"documents": f"{self.document.pk},{self.other_document.pk}"}, + queryset=ShareLinkBundle.objects.all(), + ) + + self.assertCountEqual(filterset.qs, [self.bundle_one, self.bundle_two]) + + def test_filter_documents_returns_queryset_for_empty_ids(self): + filterset = ShareLinkBundleFilterSet( + data={"documents": ","}, + queryset=ShareLinkBundle.objects.all(), + ) + + self.assertCountEqual(filterset.qs, [self.bundle_one, self.bundle_two]) + + +class ShareLinkBundleModelTests(DirectoriesMixin, APITestCase): + def test_absolute_file_path_handles_relative_and_absolute(self): + relative_path = Path("relative.zip") + bundle = ShareLinkBundle.objects.create( + slug="relative-bundle", + file_version=ShareLink.FileVersion.ORIGINAL, + file_path=str(relative_path), + ) + + self.assertEqual( + bundle.absolute_file_path, + (settings.SHARE_LINK_BUNDLE_DIR / relative_path).resolve(), + ) + + absolute_path = Path(self.dirs.media_dir) / "absolute.zip" + bundle.file_path = str(absolute_path) + + self.assertEqual(bundle.absolute_file_path.resolve(), absolute_path.resolve()) + + def test_str_returns_translated_slug(self): + bundle = ShareLinkBundle.objects.create( + slug="string-slug", + file_version=ShareLink.FileVersion.ORIGINAL, + ) + + self.assertIn("string-slug", str(bundle)) + + def test_remove_file_deletes_existing_file(self): + bundle_path = settings.SHARE_LINK_BUNDLE_DIR / "remove.zip" + bundle_path.parent.mkdir(parents=True, exist_ok=True) + bundle_path.write_bytes(b"remove-me") + bundle = ShareLinkBundle.objects.create( + slug="remove-bundle", + file_version=ShareLink.FileVersion.ORIGINAL, + file_path=str(bundle_path.relative_to(settings.SHARE_LINK_BUNDLE_DIR)), + ) + + bundle.remove_file() + + self.assertFalse(bundle_path.exists()) + + def test_remove_file_handles_oserror(self): + bundle_path = settings.SHARE_LINK_BUNDLE_DIR / "remove-error.zip" + bundle_path.parent.mkdir(parents=True, exist_ok=True) + bundle_path.write_bytes(b"remove-me") + bundle = ShareLinkBundle.objects.create( + slug="remove-error", + file_version=ShareLink.FileVersion.ORIGINAL, + file_path=str(bundle_path.relative_to(settings.SHARE_LINK_BUNDLE_DIR)), + ) + + with mock.patch("pathlib.Path.unlink", side_effect=OSError("fail")): + bundle.remove_file() + + self.assertTrue(bundle_path.exists()) + + def test_delete_calls_remove_file(self): + bundle_path = settings.SHARE_LINK_BUNDLE_DIR / "delete.zip" + bundle_path.parent.mkdir(parents=True, exist_ok=True) + bundle_path.write_bytes(b"remove-me") + bundle = ShareLinkBundle.objects.create( + slug="delete-bundle", + file_version=ShareLink.FileVersion.ORIGINAL, + file_path=str(bundle_path.relative_to(settings.SHARE_LINK_BUNDLE_DIR)), + ) + + bundle.delete() + self.assertFalse(bundle_path.exists()) + + +class ShareLinkBundleSerializerTests(DirectoriesMixin, APITestCase): + def setUp(self): + super().setUp() + self.document = DocumentFactory.create() + + def test_validate_document_ids_rejects_duplicates(self): + serializer = ShareLinkBundleSerializer( + data={ + "document_ids": [self.document.pk, self.document.pk], + "file_version": ShareLink.FileVersion.ORIGINAL, + }, + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("document_ids", serializer.errors) + + def test_create_assigns_documents_and_expiration(self): + serializer = ShareLinkBundleSerializer( + data={ + "document_ids": [self.document.pk], + "file_version": ShareLink.FileVersion.ORIGINAL, + "expiration_days": 3, + }, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + bundle = serializer.save() + + self.assertEqual(list(bundle.documents.all()), [self.document]) + expected_expiration = timezone.now() + timedelta(days=3) + self.assertAlmostEqual( + bundle.expiration, + expected_expiration, + delta=timedelta(seconds=10), + ) + + def test_create_raises_when_missing_documents(self): + serializer = ShareLinkBundleSerializer( + data={ + "document_ids": [self.document.pk, 9999], + "file_version": ShareLink.FileVersion.ORIGINAL, + }, + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + with self.assertRaises(serializers.ValidationError): + serializer.save(documents=[self.document]) diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index d2f843a68..75f9d5fe6 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -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={ diff --git a/src/documents/views.py b/src/documents/views.py index 96b1f50b0..c0f3b5db4 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 @@ -51,6 +50,7 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.timezone import make_aware from django.utils.translation import get_language +from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.decorators.cache import cache_control from django.views.decorators.csrf import ensure_csrf_cookie @@ -71,6 +71,7 @@ from packaging import version as packaging_version from redis import Redis from rest_framework import parsers from rest_framework import serializers +from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.exceptions import ValidationError @@ -121,6 +122,7 @@ from documents.filters import DocumentTypeFilterSet from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.filters import ObjectOwnedPermissionsFilter from documents.filters import PaperlessTaskFilterSet +from documents.filters import ShareLinkBundleFilterSet from documents.filters import ShareLinkFilterSet from documents.filters import StoragePathFilterSet from documents.filters import TagFilterSet @@ -138,6 +140,7 @@ from documents.models import Note from documents.models import PaperlessTask from documents.models import SavedView from documents.models import ShareLink +from documents.models import ShareLinkBundle from documents.models import StoragePath from documents.models import Tag from documents.models import UiSettings @@ -171,6 +174,7 @@ from documents.serialisers import PostDocumentSerializer from documents.serialisers import RunTaskViewSerializer from documents.serialisers import SavedViewSerializer from documents.serialisers import SearchResultSerializer +from documents.serialisers import ShareLinkBundleSerializer from documents.serialisers import ShareLinkSerializer from documents.serialisers import StoragePathSerializer from documents.serialisers import StoragePathTestSerializer @@ -183,6 +187,7 @@ from documents.serialisers import WorkflowActionSerializer from documents.serialisers import WorkflowSerializer from documents.serialisers import WorkflowTriggerSerializer from documents.signals import document_updated +from documents.tasks import build_share_link_bundle from documents.tasks import consume_file from documents.tasks import empty_trash from documents.tasks import index_optimize @@ -479,11 +484,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 +500,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 +2331,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 +2372,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 +2411,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), @@ -2436,7 +2441,7 @@ class BulkDownloadView(GenericAPIView): follow_filename_format = serializer.validated_data.get("follow_formatting") for document in documents: - if not has_perms_owner_aware(request.user, "view_document", document): + if not has_perms_owner_aware(request.user, "change_document", document): return HttpResponseForbidden("Insufficient permissions") settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True) @@ -2791,21 +2796,187 @@ class ShareLinkViewSet(ModelViewSet, PassUserMixin): ordering_fields = ("created", "expiration", "document") +class ShareLinkBundleViewSet(ModelViewSet, PassUserMixin): + model = ShareLinkBundle + + queryset = ShareLinkBundle.objects.all() + + serializer_class = ShareLinkBundleSerializer + pagination_class = StandardPagination + permission_classes = (IsAuthenticated, PaperlessObjectPermissions) + filter_backends = ( + DjangoFilterBackend, + OrderingFilter, + ObjectOwnedOrGrantedPermissionsFilter, + ) + filterset_class = ShareLinkBundleFilterSet + ordering_fields = ("created", "expiration", "status") + + def get_queryset(self): + return ( + super() + .get_queryset() + .prefetch_related("documents") + .annotate(document_total=Count("documents", distinct=True)) + ) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + document_ids = serializer.validated_data["document_ids"] + documents_qs = Document.objects.filter(pk__in=document_ids).select_related( + "owner", + ) + found_ids = set(documents_qs.values_list("pk", flat=True)) + missing = sorted(set(document_ids) - found_ids) + if missing: + raise ValidationError( + { + "document_ids": _( + "Documents not found: %(ids)s", + ) + % {"ids": ", ".join(str(item) for item in missing)}, + }, + ) + + documents = list(documents_qs) + for document in documents: + if not has_perms_owner_aware(request.user, "view_document", document): + raise ValidationError( + { + "document_ids": _( + "Insufficient permissions to share document %(id)s.", + ) + % {"id": document.pk}, + }, + ) + + document_map = {document.pk: document for document in documents} + ordered_documents = [document_map[doc_id] for doc_id in document_ids] + + bundle = serializer.save( + owner=request.user, + documents=ordered_documents, + ) + bundle.remove_file() + bundle.status = ShareLinkBundle.Status.PENDING + bundle.last_error = None + bundle.size_bytes = None + bundle.built_at = None + bundle.file_path = "" + bundle.save( + update_fields=[ + "status", + "last_error", + "size_bytes", + "built_at", + "file_path", + ], + ) + build_share_link_bundle.delay(bundle.pk) + bundle.document_total = len(ordered_documents) + response_serializer = self.get_serializer(bundle) + headers = self.get_success_headers(response_serializer.data) + return Response( + response_serializer.data, + status=status.HTTP_201_CREATED, + headers=headers, + ) + + @action(detail=True, methods=["post"]) + def rebuild(self, request, pk=None): + bundle = self.get_object() + if bundle.status == ShareLinkBundle.Status.PROCESSING: + return Response( + {"detail": _("Bundle is already being processed.")}, + status=status.HTTP_400_BAD_REQUEST, + ) + bundle.remove_file() + bundle.status = ShareLinkBundle.Status.PENDING + bundle.last_error = None + bundle.size_bytes = None + bundle.built_at = None + bundle.file_path = "" + bundle.save( + update_fields=[ + "status", + "last_error", + "size_bytes", + "built_at", + "file_path", + ], + ) + build_share_link_bundle.delay(bundle.pk) + bundle.document_total = ( + getattr(bundle, "document_total", None) or bundle.documents.count() + ) + serializer = self.get_serializer(bundle) + return Response(serializer.data) + + class SharedLinkView(View): authentication_classes = [] permission_classes = [] def get(self, request, slug): share_link = ShareLink.objects.filter(slug=slug).first() - if share_link is None: + if share_link is not None: + if ( + share_link.expiration is not None + and share_link.expiration < timezone.now() + ): + return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") + return serve_file( + doc=share_link.document, + use_archive=share_link.file_version == "archive", + disposition="inline", + ) + + bundle = ShareLinkBundle.objects.filter(slug=slug).first() + if bundle is None: return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1") - if share_link.expiration is not None and share_link.expiration < timezone.now(): + + if bundle.expiration is not None and bundle.expiration < timezone.now(): return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") - return serve_file( - doc=share_link.document, - use_archive=share_link.file_version == "archive", - disposition="inline", + + if bundle.status in { + ShareLinkBundle.Status.PENDING, + ShareLinkBundle.Status.PROCESSING, + }: + return HttpResponse( + _( + "The share link bundle is still being prepared. Please try again later.", + ), + status=status.HTTP_202_ACCEPTED, + ) + + file_path = bundle.absolute_file_path + + if bundle.status == ShareLinkBundle.Status.FAILED or file_path is None: + return HttpResponse( + _( + "The share link bundle is unavailable.", + ), + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + response = FileResponse(file_path.open("rb"), content_type="application/zip") + short_slug = bundle.slug[:12] + download_name = f"paperless-share-{short_slug}.zip" + filename_normalized = ( + normalize("NFKD", download_name) + .encode( + "ascii", + "ignore", + ) + .decode("ascii") ) + filename_encoded = quote(download_name) + response["Content-Disposition"] = ( + f"attachment; filename='{filename_normalized}'; " + f"filename*=utf-8''{filename_encoded}" + ) + return response def serve_file(*, doc: Document, use_archive: bool, disposition: str): diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 7e4bf0abf..736ea3ee1 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 03:30+0000\n" +"POT-Creation-Date: 2026-01-27 18:56+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -21,1229 +21,1295 @@ msgstr "" msgid "Documents" msgstr "" -#: documents/filters.py:395 +#: documents/filters.py:396 msgid "Value must be valid JSON." msgstr "" -#: documents/filters.py:414 +#: documents/filters.py:415 msgid "Invalid custom field query expression" msgstr "" -#: documents/filters.py:424 +#: documents/filters.py:425 msgid "Invalid expression list. Must be nonempty." msgstr "" -#: documents/filters.py:445 +#: documents/filters.py:446 msgid "Invalid logical operator {op!r}" msgstr "" -#: documents/filters.py:459 +#: documents/filters.py:460 msgid "Maximum number of query conditions exceeded." msgstr "" -#: documents/filters.py:524 +#: documents/filters.py:525 msgid "{name!r} is not a valid custom field." msgstr "" -#: documents/filters.py:561 +#: documents/filters.py:562 msgid "{data_type} does not support query expr {expr!r}." msgstr "" -#: documents/filters.py:669 documents/models.py:135 +#: documents/filters.py:670 documents/models.py:137 msgid "Maximum nesting depth exceeded." msgstr "" -#: documents/filters.py:854 +#: documents/filters.py:878 msgid "Custom field not found" msgstr "" -#: documents/models.py:38 documents/models.py:747 +#: documents/models.py:40 documents/models.py:757 documents/models.py:805 msgid "owner" msgstr "" -#: documents/models.py:55 documents/models.py:962 +#: documents/models.py:57 documents/models.py:1080 msgid "None" msgstr "" -#: documents/models.py:56 documents/models.py:963 +#: documents/models.py:58 documents/models.py:1081 msgid "Any word" msgstr "" -#: documents/models.py:57 documents/models.py:964 +#: documents/models.py:59 documents/models.py:1082 msgid "All words" msgstr "" -#: documents/models.py:58 documents/models.py:965 +#: documents/models.py:60 documents/models.py:1083 msgid "Exact match" msgstr "" -#: documents/models.py:59 documents/models.py:966 +#: documents/models.py:61 documents/models.py:1084 msgid "Regular expression" msgstr "" -#: documents/models.py:60 documents/models.py:967 +#: documents/models.py:62 documents/models.py:1085 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:1507 +#: documents/models.py:66 documents/models.py:444 documents/models.py:1646 #: 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:1149 msgid "match" msgstr "" -#: documents/models.py:69 documents/models.py:1034 +#: documents/models.py:71 documents/models.py:1152 msgid "matching algorithm" msgstr "" -#: documents/models.py:74 documents/models.py:1039 +#: documents/models.py:76 documents/models.py:1157 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:777 documents/models.py:895 documents/models.py:954 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:1550 +#: documents/models.py:313 documents/models.py:688 documents/models.py:742 +#: documents/models.py:1689 msgid "document" msgstr "" -#: documents/models.py:304 +#: documents/models.py:314 documents/models.py:848 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:1101 msgid "Created" msgstr "" -#: documents/models.py:422 documents/models.py:982 +#: documents/models.py:432 documents/models.py:1100 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 documents/models.py:785 paperless_mail/models.py:75 msgid "expiration" msgstr "" -#: documents/models.py:720 +#: documents/models.py:730 documents/models.py:792 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 -msgid "String" -msgstr "" - -#: documents/models.py:766 -msgid "URL" -msgstr "" - -#: documents/models.py:767 -msgid "Date" -msgstr "" - -#: documents/models.py:768 -msgid "Boolean" -msgstr "" - -#: documents/models.py:769 -msgid "Integer" -msgstr "" - -#: documents/models.py:770 -msgid "Float" -msgstr "" - #: documents/models.py:771 -msgid "Monetary" +msgid "Pending" msgstr "" #: documents/models.py:772 -msgid "Document Link" +msgid "Processing" msgstr "" #: documents/models.py:773 -msgid "Select" +msgid "Ready" msgstr "" #: documents/models.py:774 +msgid "Failed" +msgstr "" + +#: documents/models.py:821 +msgid "size (bytes)" +msgstr "" + +#: documents/models.py:827 +msgid "last error" +msgstr "" + +#: documents/models.py:834 +msgid "file path" +msgstr "" + +#: documents/models.py:840 +msgid "built at" +msgstr "" + +#: documents/models.py:853 +msgid "share link bundle" +msgstr "" + +#: documents/models.py:854 +msgid "share link bundles" +msgstr "" + +#: documents/models.py:857 +#, python-format +msgid "Share link bundle %(slug)s" +msgstr "" + +#: documents/models.py:883 +msgid "String" +msgstr "" + +#: documents/models.py:884 +msgid "URL" +msgstr "" + +#: documents/models.py:885 +msgid "Date" +msgstr "" + +#: documents/models.py:886 +msgid "Boolean" +msgstr "" + +#: documents/models.py:887 +msgid "Integer" +msgstr "" + +#: documents/models.py:888 +msgid "Float" +msgstr "" + +#: documents/models.py:889 +msgid "Monetary" +msgstr "" + +#: documents/models.py:890 +msgid "Document Link" +msgstr "" + +#: documents/models.py:891 +msgid "Select" +msgstr "" + +#: documents/models.py:892 msgid "Long Text" msgstr "" -#: documents/models.py:786 +#: documents/models.py:904 msgid "data type" msgstr "" -#: documents/models.py:793 +#: documents/models.py:911 msgid "extra data" msgstr "" -#: documents/models.py:797 +#: documents/models.py:915 msgid "Extra data for the custom field, such as select options" msgstr "" -#: documents/models.py:803 +#: documents/models.py:921 msgid "custom field" msgstr "" -#: documents/models.py:804 +#: documents/models.py:922 msgid "custom fields" msgstr "" -#: documents/models.py:904 +#: documents/models.py:1022 msgid "custom field instance" msgstr "" -#: documents/models.py:905 +#: documents/models.py:1023 msgid "custom field instances" msgstr "" -#: documents/models.py:970 +#: documents/models.py:1088 msgid "Consumption Started" msgstr "" -#: documents/models.py:971 +#: documents/models.py:1089 msgid "Document Added" msgstr "" -#: documents/models.py:972 +#: documents/models.py:1090 msgid "Document Updated" msgstr "" -#: documents/models.py:973 +#: documents/models.py:1091 msgid "Scheduled" msgstr "" -#: documents/models.py:976 +#: documents/models.py:1094 msgid "Consume Folder" msgstr "" -#: documents/models.py:977 +#: documents/models.py:1095 msgid "Api Upload" msgstr "" -#: documents/models.py:978 +#: documents/models.py:1096 msgid "Mail Fetch" msgstr "" -#: documents/models.py:979 +#: documents/models.py:1097 msgid "Web UI" msgstr "" -#: documents/models.py:984 +#: documents/models.py:1102 msgid "Modified" msgstr "" -#: documents/models.py:985 +#: documents/models.py:1103 msgid "Custom Field" msgstr "" -#: documents/models.py:988 +#: documents/models.py:1106 msgid "Workflow Trigger Type" msgstr "" -#: documents/models.py:1000 +#: documents/models.py:1118 msgid "filter path" msgstr "" -#: documents/models.py:1005 +#: documents/models.py:1123 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:1130 msgid "filter filename" msgstr "" -#: documents/models.py:1017 paperless_mail/models.py:200 +#: documents/models.py:1135 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:1146 msgid "filter documents from this mail rule" msgstr "" -#: documents/models.py:1044 +#: documents/models.py:1162 msgid "has these tag(s)" msgstr "" -#: documents/models.py:1051 +#: documents/models.py:1169 msgid "has all of these tag(s)" msgstr "" -#: documents/models.py:1058 +#: documents/models.py:1176 msgid "does not have these tag(s)" msgstr "" -#: documents/models.py:1066 +#: documents/models.py:1184 msgid "has this document type" msgstr "" -#: documents/models.py:1073 +#: documents/models.py:1191 +msgid "has one of these document types" +msgstr "" + +#: documents/models.py:1198 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1081 +#: documents/models.py:1206 msgid "has this correspondent" msgstr "" -#: documents/models.py:1088 +#: documents/models.py:1213 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1096 +#: documents/models.py:1220 +msgid "has one of these correspondents" +msgstr "" + +#: documents/models.py:1228 msgid "has this storage path" msgstr "" -#: documents/models.py:1103 +#: documents/models.py:1235 +msgid "has one of these storage paths" +msgstr "" + +#: documents/models.py:1242 msgid "does not have these storage path(s)" msgstr "" -#: documents/models.py:1107 +#: documents/models.py:1246 msgid "filter custom field query" msgstr "" -#: documents/models.py:1110 +#: documents/models.py:1249 msgid "JSON-encoded custom field query expression." msgstr "" -#: documents/models.py:1114 +#: documents/models.py:1253 msgid "schedule offset days" msgstr "" -#: documents/models.py:1117 +#: documents/models.py:1256 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1122 +#: documents/models.py:1261 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1125 +#: documents/models.py:1264 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1130 +#: documents/models.py:1269 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1134 +#: documents/models.py:1273 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1139 +#: documents/models.py:1278 msgid "schedule date field" msgstr "" -#: documents/models.py:1144 +#: documents/models.py:1283 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1153 +#: documents/models.py:1292 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1157 +#: documents/models.py:1296 msgid "workflow trigger" msgstr "" -#: documents/models.py:1158 +#: documents/models.py:1297 msgid "workflow triggers" msgstr "" -#: documents/models.py:1166 +#: documents/models.py:1305 msgid "email subject" msgstr "" -#: documents/models.py:1170 +#: documents/models.py:1309 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1176 +#: documents/models.py:1315 msgid "email body" msgstr "" -#: documents/models.py:1179 +#: documents/models.py:1318 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1185 +#: documents/models.py:1324 msgid "emails to" msgstr "" -#: documents/models.py:1188 +#: documents/models.py:1327 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1194 +#: documents/models.py:1333 msgid "include document in email" msgstr "" -#: documents/models.py:1205 +#: documents/models.py:1344 msgid "webhook url" msgstr "" -#: documents/models.py:1208 +#: documents/models.py:1347 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1213 +#: documents/models.py:1352 msgid "use parameters" msgstr "" -#: documents/models.py:1218 +#: documents/models.py:1357 msgid "send as JSON" msgstr "" -#: documents/models.py:1222 +#: documents/models.py:1361 msgid "webhook parameters" msgstr "" -#: documents/models.py:1225 +#: documents/models.py:1364 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1229 +#: documents/models.py:1368 msgid "webhook body" msgstr "" -#: documents/models.py:1232 +#: documents/models.py:1371 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1236 +#: documents/models.py:1375 msgid "webhook headers" msgstr "" -#: documents/models.py:1239 +#: documents/models.py:1378 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1244 +#: documents/models.py:1383 msgid "include document in webhook" msgstr "" -#: documents/models.py:1255 +#: documents/models.py:1394 msgid "Assignment" msgstr "" -#: documents/models.py:1259 +#: documents/models.py:1398 msgid "Removal" msgstr "" -#: documents/models.py:1263 documents/templates/account/password_reset.html:15 +#: documents/models.py:1402 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1267 +#: documents/models.py:1406 msgid "Webhook" msgstr "" -#: documents/models.py:1271 +#: documents/models.py:1410 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1276 documents/models.py:1509 +#: documents/models.py:1415 documents/models.py:1648 #: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1279 +#: documents/models.py:1418 msgid "assign title" msgstr "" -#: documents/models.py:1283 +#: documents/models.py:1422 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:1430 paperless_mail/models.py:274 msgid "assign this tag" msgstr "" -#: documents/models.py:1300 paperless_mail/models.py:282 +#: documents/models.py:1439 paperless_mail/models.py:282 msgid "assign this document type" msgstr "" -#: documents/models.py:1309 paperless_mail/models.py:296 +#: documents/models.py:1448 paperless_mail/models.py:296 msgid "assign this correspondent" msgstr "" -#: documents/models.py:1318 +#: documents/models.py:1457 msgid "assign this storage path" msgstr "" -#: documents/models.py:1327 +#: documents/models.py:1466 msgid "assign this owner" msgstr "" -#: documents/models.py:1334 +#: documents/models.py:1473 msgid "grant view permissions to these users" msgstr "" -#: documents/models.py:1341 +#: documents/models.py:1480 msgid "grant view permissions to these groups" msgstr "" -#: documents/models.py:1348 +#: documents/models.py:1487 msgid "grant change permissions to these users" msgstr "" -#: documents/models.py:1355 +#: documents/models.py:1494 msgid "grant change permissions to these groups" msgstr "" -#: documents/models.py:1362 +#: documents/models.py:1501 msgid "assign these custom fields" msgstr "" -#: documents/models.py:1366 +#: documents/models.py:1505 msgid "custom field values" msgstr "" -#: documents/models.py:1370 +#: documents/models.py:1509 msgid "Optional values to assign to the custom fields." msgstr "" -#: documents/models.py:1379 +#: documents/models.py:1518 msgid "remove these tag(s)" msgstr "" -#: documents/models.py:1384 +#: documents/models.py:1523 msgid "remove all tags" msgstr "" -#: documents/models.py:1391 +#: documents/models.py:1530 msgid "remove these document type(s)" msgstr "" -#: documents/models.py:1396 +#: documents/models.py:1535 msgid "remove all document types" msgstr "" -#: documents/models.py:1403 +#: documents/models.py:1542 msgid "remove these correspondent(s)" msgstr "" -#: documents/models.py:1408 +#: documents/models.py:1547 msgid "remove all correspondents" msgstr "" -#: documents/models.py:1415 +#: documents/models.py:1554 msgid "remove these storage path(s)" msgstr "" -#: documents/models.py:1420 +#: documents/models.py:1559 msgid "remove all storage paths" msgstr "" -#: documents/models.py:1427 +#: documents/models.py:1566 msgid "remove these owner(s)" msgstr "" -#: documents/models.py:1432 +#: documents/models.py:1571 msgid "remove all owners" msgstr "" -#: documents/models.py:1439 +#: documents/models.py:1578 msgid "remove view permissions for these users" msgstr "" -#: documents/models.py:1446 +#: documents/models.py:1585 msgid "remove view permissions for these groups" msgstr "" -#: documents/models.py:1453 +#: documents/models.py:1592 msgid "remove change permissions for these users" msgstr "" -#: documents/models.py:1460 +#: documents/models.py:1599 msgid "remove change permissions for these groups" msgstr "" -#: documents/models.py:1465 +#: documents/models.py:1604 msgid "remove all permissions" msgstr "" -#: documents/models.py:1472 +#: documents/models.py:1611 msgid "remove these custom fields" msgstr "" -#: documents/models.py:1477 +#: documents/models.py:1616 msgid "remove all custom fields" msgstr "" -#: documents/models.py:1486 +#: documents/models.py:1625 msgid "email" msgstr "" -#: documents/models.py:1495 +#: documents/models.py:1634 msgid "webhook" msgstr "" -#: documents/models.py:1499 +#: documents/models.py:1638 msgid "workflow action" msgstr "" -#: documents/models.py:1500 +#: documents/models.py:1639 msgid "workflow actions" msgstr "" -#: documents/models.py:1515 +#: documents/models.py:1654 msgid "triggers" msgstr "" -#: documents/models.py:1522 +#: documents/models.py:1661 msgid "actions" msgstr "" -#: documents/models.py:1525 paperless_mail/models.py:154 +#: documents/models.py:1664 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:1536 +#: documents/models.py:1675 msgid "workflow" msgstr "" -#: documents/models.py:1540 +#: documents/models.py:1679 msgid "workflow trigger type" msgstr "" -#: documents/models.py:1554 +#: documents/models.py:1693 msgid "date run" msgstr "" -#: documents/models.py:1560 +#: documents/models.py:1699 msgid "workflow run" msgstr "" -#: documents/models.py:1561 +#: documents/models.py:1700 msgid "workflow runs" msgstr "" -#: documents/serialisers.py:646 +#: documents/serialisers.py:654 msgid "Invalid color." msgstr "" -#: documents/serialisers.py:1850 +#: documents/serialisers.py:1896 #, python-format msgid "File type %(type)s not supported" msgstr "" -#: documents/serialisers.py:1894 +#: documents/serialisers.py:1940 #, python-format msgid "Custom field id must be an integer: %(id)s" msgstr "" -#: documents/serialisers.py:1901 +#: documents/serialisers.py:1947 #, python-format msgid "Custom field with id %(id)s does not exist" msgstr "" -#: documents/serialisers.py:1918 documents/serialisers.py:1928 +#: documents/serialisers.py:1964 documents/serialisers.py:1974 msgid "" "Custom fields must be a list of integers or an object mapping ids to values." msgstr "" -#: documents/serialisers.py:1923 +#: documents/serialisers.py:1969 msgid "Some custom fields don't exist or were specified twice." msgstr "" -#: documents/serialisers.py:2038 +#: documents/serialisers.py:2084 msgid "Invalid variable detected." msgstr "" +#: documents/serialisers.py:2286 +msgid "Duplicate document identifiers are not allowed." +msgstr "" + +#: documents/serialisers.py:2316 documents/views.py:2836 +#, python-format +msgid "Documents not found: %(ids)s" +msgstr "" + #: documents/templates/account/account_inactive.html:5 msgid "Paperless-ngx account inactive" msgstr "" @@ -1503,6 +1569,23 @@ msgstr "" msgid "Unable to parse URI {value}" msgstr "" +#: documents/views.py:2848 +#, python-format +msgid "Insufficient permissions to share document %(id)s." +msgstr "" + +#: documents/views.py:2891 +msgid "Bundle is already being processed." +msgstr "" + +#: documents/views.py:2948 +msgid "The share link bundle is still being prepared. Please try again later." +msgstr "" + +#: documents/views.py:2958 +msgid "The share link bundle is unavailable." +msgstr "" + #: paperless/apps.py:11 msgid "Paperless" msgstr "" @@ -1735,155 +1818,155 @@ msgstr "" msgid "paperless application settings" msgstr "" -#: paperless/settings.py:800 +#: paperless/settings.py:819 msgid "English (US)" msgstr "" -#: paperless/settings.py:801 +#: paperless/settings.py:820 msgid "Arabic" msgstr "" -#: paperless/settings.py:802 +#: paperless/settings.py:821 msgid "Afrikaans" msgstr "" -#: paperless/settings.py:803 +#: paperless/settings.py:822 msgid "Belarusian" msgstr "" -#: paperless/settings.py:804 +#: paperless/settings.py:823 msgid "Bulgarian" msgstr "" -#: paperless/settings.py:805 +#: paperless/settings.py:824 msgid "Catalan" msgstr "" -#: paperless/settings.py:806 +#: paperless/settings.py:825 msgid "Czech" msgstr "" -#: paperless/settings.py:807 +#: paperless/settings.py:826 msgid "Danish" msgstr "" -#: paperless/settings.py:808 +#: paperless/settings.py:827 msgid "German" msgstr "" -#: paperless/settings.py:809 +#: paperless/settings.py:828 msgid "Greek" msgstr "" -#: paperless/settings.py:810 +#: paperless/settings.py:829 msgid "English (GB)" msgstr "" -#: paperless/settings.py:811 +#: paperless/settings.py:830 msgid "Spanish" msgstr "" -#: paperless/settings.py:812 +#: paperless/settings.py:831 msgid "Persian" msgstr "" -#: paperless/settings.py:813 +#: paperless/settings.py:832 msgid "Finnish" msgstr "" -#: paperless/settings.py:814 +#: paperless/settings.py:833 msgid "French" msgstr "" -#: paperless/settings.py:815 +#: paperless/settings.py:834 msgid "Hungarian" msgstr "" -#: paperless/settings.py:816 +#: paperless/settings.py:835 msgid "Indonesian" msgstr "" -#: paperless/settings.py:817 +#: paperless/settings.py:836 msgid "Italian" msgstr "" -#: paperless/settings.py:818 +#: paperless/settings.py:837 msgid "Japanese" msgstr "" -#: paperless/settings.py:819 +#: paperless/settings.py:838 msgid "Korean" msgstr "" -#: paperless/settings.py:820 +#: paperless/settings.py:839 msgid "Luxembourgish" msgstr "" -#: paperless/settings.py:821 +#: paperless/settings.py:840 msgid "Norwegian" msgstr "" -#: paperless/settings.py:822 +#: paperless/settings.py:841 msgid "Dutch" msgstr "" -#: paperless/settings.py:823 +#: paperless/settings.py:842 msgid "Polish" msgstr "" -#: paperless/settings.py:824 +#: paperless/settings.py:843 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings.py:825 +#: paperless/settings.py:844 msgid "Portuguese" msgstr "" -#: paperless/settings.py:826 +#: paperless/settings.py:845 msgid "Romanian" msgstr "" -#: paperless/settings.py:827 +#: paperless/settings.py:846 msgid "Russian" msgstr "" -#: paperless/settings.py:828 +#: paperless/settings.py:847 msgid "Slovak" msgstr "" -#: paperless/settings.py:829 +#: paperless/settings.py:848 msgid "Slovenian" msgstr "" -#: paperless/settings.py:830 +#: paperless/settings.py:849 msgid "Serbian" msgstr "" -#: paperless/settings.py:831 +#: paperless/settings.py:850 msgid "Swedish" msgstr "" -#: paperless/settings.py:832 +#: paperless/settings.py:851 msgid "Turkish" msgstr "" -#: paperless/settings.py:833 +#: paperless/settings.py:852 msgid "Ukrainian" msgstr "" -#: paperless/settings.py:834 +#: paperless/settings.py:853 msgid "Vietnamese" msgstr "" -#: paperless/settings.py:835 +#: paperless/settings.py:854 msgid "Chinese Simplified" msgstr "" -#: paperless/settings.py:836 +#: paperless/settings.py:855 msgid "Chinese Traditional" msgstr "" -#: paperless/urls.py:376 +#: paperless/urls.py:379 msgid "Paperless-ngx administration" msgstr "" diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py index a4506275e..bd6a86f77 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.strategies.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..3d93d8e40 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -241,6 +241,17 @@ def _parse_beat_schedule() -> dict: "expires": 23.0 * 60.0 * 60.0, }, }, + { + "name": "Cleanup expired share link bundles", + "env_key": "PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON", + # Default daily at 02:00 + "env_default": "0 2 * * *", + "task": "documents.tasks.cleanup_expired_share_link_bundles", + "options": { + # 1 hour before default schedule sends again + "expires": 23.0 * 60.0 * 60.0, + }, + }, ] for task in tasks: # Either get the environment setting or use the default @@ -279,6 +290,7 @@ MEDIA_ROOT = __get_path("PAPERLESS_MEDIA_ROOT", BASE_DIR.parent / "media") ORIGINALS_DIR = MEDIA_ROOT / "documents" / "originals" ARCHIVE_DIR = MEDIA_ROOT / "documents" / "archive" THUMBNAIL_DIR = MEDIA_ROOT / "documents" / "thumbnails" +SHARE_LINK_BUNDLE_DIR = MEDIA_ROOT / "documents" / "share_link_bundles" DATA_DIR = __get_path("PAPERLESS_DATA_DIR", BASE_DIR.parent / "data") @@ -345,6 +357,7 @@ INSTALLED_APPS = [ "allauth.account", "allauth.socialaccount", "allauth.mfa", + "allauth.headless", "drf_spectacular", "drf_spectacular_sidecar", "treenode", @@ -539,6 +552,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/tests/test_settings.py b/src/paperless/tests/test_settings.py index f09ddcefa..9957de4fc 100644 --- a/src/paperless/tests/test_settings.py +++ b/src/paperless/tests/test_settings.py @@ -161,6 +161,7 @@ class TestCeleryScheduleParsing(TestCase): EMPTY_TRASH_EXPIRE_TIME = 23.0 * 60.0 * 60.0 RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME = 59.0 * 60.0 LLM_INDEX_EXPIRE_TIME = 23.0 * 60.0 * 60.0 + CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME = 23.0 * 60.0 * 60.0 def test_schedule_configuration_default(self): """ @@ -212,6 +213,13 @@ class TestCeleryScheduleParsing(TestCase): "expires": self.LLM_INDEX_EXPIRE_TIME, }, }, + "Cleanup expired share link bundles": { + "task": "documents.tasks.cleanup_expired_share_link_bundles", + "schedule": crontab(minute=0, hour=2), + "options": { + "expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME, + }, + }, }, schedule, ) @@ -271,6 +279,13 @@ class TestCeleryScheduleParsing(TestCase): "expires": self.LLM_INDEX_EXPIRE_TIME, }, }, + "Cleanup expired share link bundles": { + "task": "documents.tasks.cleanup_expired_share_link_bundles", + "schedule": crontab(minute=0, hour=2), + "options": { + "expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME, + }, + }, }, schedule, ) @@ -322,6 +337,13 @@ class TestCeleryScheduleParsing(TestCase): "expires": self.LLM_INDEX_EXPIRE_TIME, }, }, + "Cleanup expired share link bundles": { + "task": "documents.tasks.cleanup_expired_share_link_bundles", + "schedule": crontab(minute=0, hour=2), + "options": { + "expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME, + }, + }, }, schedule, ) @@ -345,6 +367,7 @@ class TestCeleryScheduleParsing(TestCase): "PAPERLESS_EMPTY_TRASH_TASK_CRON": "disable", "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON": "disable", "PAPERLESS_LLM_INDEX_TASK_CRON": "disable", + "PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON": "disable", }, ): schedule = _parse_beat_schedule() diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 179af14e0..04b0c2200 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -31,6 +31,7 @@ from documents.views import SavedViewViewSet from documents.views import SearchAutoCompleteView from documents.views import SelectionDataView from documents.views import SharedLinkView +from documents.views import ShareLinkBundleViewSet from documents.views import ShareLinkViewSet from documents.views import StatisticsView from documents.views import StoragePathViewSet @@ -73,6 +74,7 @@ api_router.register(r"users", UserViewSet, basename="users") api_router.register(r"groups", GroupViewSet, basename="groups") api_router.register(r"mail_accounts", MailAccountViewSet) api_router.register(r"mail_rules", MailRuleViewSet) +api_router.register(r"share_link_bundles", ShareLinkBundleViewSet) api_router.register(r"share_links", ShareLinkViewSet) api_router.register(r"workflow_triggers", WorkflowTriggerViewSet) api_router.register(r"workflow_actions", WorkflowActionViewSet) @@ -228,6 +230,7 @@ urlpatterns = [ ], ), ), + re_path("^auth/headless/", include("allauth.headless.urls")), re_path( "^$", # Redirect to the API swagger view RedirectView.as_view(url="schema/view/"), diff --git a/src/paperless_ai/indexing.py b/src/paperless_ai/indexing.py index 03c8aa9be..654c56f3b 100644 --- a/src/paperless_ai/indexing.py +++ b/src/paperless_ai/indexing.py @@ -1,11 +1,14 @@ import logging import shutil +from datetime import timedelta from pathlib import Path import faiss import llama_index.core.settings as llama_settings import tqdm +from celery import states from django.conf import settings +from django.utils import timezone from llama_index.core import Document as LlamaDocument from llama_index.core import StorageContext from llama_index.core import VectorStoreIndex @@ -21,6 +24,7 @@ from llama_index.core.text_splitter import TokenTextSplitter from llama_index.vector_stores.faiss import FaissVectorStore from documents.models import Document +from documents.models import PaperlessTask from paperless_ai.embedding import build_llm_index_text from paperless_ai.embedding import get_embedding_dim from paperless_ai.embedding import get_embedding_model @@ -28,6 +32,29 @@ from paperless_ai.embedding import get_embedding_model logger = logging.getLogger("paperless_ai.indexing") +def queue_llm_index_update_if_needed(*, rebuild: bool, reason: str) -> bool: + from documents.tasks import llmindex_index + + has_running = PaperlessTask.objects.filter( + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + status__in=[states.PENDING, states.STARTED], + ).exists() + has_recent = PaperlessTask.objects.filter( + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + date_created__gte=(timezone.now() - timedelta(minutes=5)), + ).exists() + if has_running or has_recent: + return False + + llmindex_index.delay(rebuild=rebuild, scheduled=False, auto=True) + logger.warning( + "Queued LLM index update%s: %s", + " (rebuild)" if rebuild else "", + reason, + ) + return True + + def get_or_create_storage_context(*, rebuild=False): """ Loads or creates the StorageContext (vector store, docstore, index store). @@ -93,6 +120,10 @@ def load_or_build_index(nodes=None): except ValueError as e: logger.warning("Failed to load index from storage: %s", e) if not nodes: + queue_llm_index_update_if_needed( + rebuild=vector_store_file_exists(), + reason="LLM index missing or invalid while loading.", + ) logger.info("No nodes provided for index creation.") raise return VectorStoreIndex( @@ -250,6 +281,13 @@ def query_similar_documents( """ Runs a similarity query and returns top-k similar Document objects. """ + if not vector_store_file_exists(): + queue_llm_index_update_if_needed( + rebuild=False, + reason="LLM index not found for similarity query.", + ) + return [] + index = load_or_build_index() # constrain only the node(s) that match the document IDs, if given diff --git a/src/paperless_ai/tests/test_ai_indexing.py b/src/paperless_ai/tests/test_ai_indexing.py index bd217fb89..7505d49b0 100644 --- a/src/paperless_ai/tests/test_ai_indexing.py +++ b/src/paperless_ai/tests/test_ai_indexing.py @@ -3,11 +3,13 @@ from unittest.mock import MagicMock from unittest.mock import patch import pytest +from celery import states from django.test import override_settings from django.utils import timezone from llama_index.core.base.embeddings.base import BaseEmbedding from documents.models import Document +from documents.models import PaperlessTask from paperless_ai import indexing @@ -288,6 +290,36 @@ def test_update_llm_index_no_documents( ) +@pytest.mark.django_db +def test_queue_llm_index_update_if_needed_enqueues_when_idle_or_skips_recent(): + # No existing tasks + with patch("documents.tasks.llmindex_index") as mock_task: + result = indexing.queue_llm_index_update_if_needed( + rebuild=True, + reason="test enqueue", + ) + + assert result is True + mock_task.delay.assert_called_once_with(rebuild=True, scheduled=False, auto=True) + + PaperlessTask.objects.create( + task_id="task-1", + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + status=states.STARTED, + date_created=timezone.now(), + ) + + # Existing running task + with patch("documents.tasks.llmindex_index") as mock_task: + result = indexing.queue_llm_index_update_if_needed( + rebuild=False, + reason="should skip", + ) + + assert result is False + mock_task.delay.assert_not_called() + + @override_settings( LLM_EMBEDDING_BACKEND="huggingface", LLM_BACKEND="ollama", @@ -299,11 +331,15 @@ def test_query_similar_documents( with ( patch("paperless_ai.indexing.get_or_create_storage_context") as mock_storage, patch("paperless_ai.indexing.load_or_build_index") as mock_load_or_build_index, + patch( + "paperless_ai.indexing.vector_store_file_exists", + ) as mock_vector_store_exists, patch("paperless_ai.indexing.VectorIndexRetriever") as mock_retriever_cls, patch("paperless_ai.indexing.Document.objects.filter") as mock_filter, ): mock_storage.return_value = MagicMock() mock_storage.return_value.persist_dir = temp_llm_index_dir + mock_vector_store_exists.return_value = True mock_index = MagicMock() mock_load_or_build_index.return_value = mock_index @@ -332,3 +368,31 @@ def test_query_similar_documents( mock_filter.assert_called_once_with(pk__in=[1, 2]) assert result == mock_filtered_docs + + +@pytest.mark.django_db +def test_query_similar_documents_triggers_update_when_index_missing( + temp_llm_index_dir, + real_document, +): + with ( + patch( + "paperless_ai.indexing.vector_store_file_exists", + return_value=False, + ), + patch( + "paperless_ai.indexing.queue_llm_index_update_if_needed", + ) as mock_queue, + patch("paperless_ai.indexing.load_or_build_index") as mock_load, + ): + result = indexing.query_similar_documents( + real_document, + top_k=2, + ) + + mock_queue.assert_called_once_with( + rebuild=False, + reason="LLM index not found for similarity query.", + ) + mock_load.assert_not_called() + assert result == [] diff --git a/src/paperless_mail/tests/conftest.py b/src/paperless_mail/tests/conftest.py index 01a98d57d..d6b74dfbf 100644 --- a/src/paperless_mail/tests/conftest.py +++ b/src/paperless_mail/tests/conftest.py @@ -1,4 +1,3 @@ -import os from collections.abc import Generator from pathlib import Path @@ -70,18 +69,21 @@ def mail_parser() -> MailDocumentParser: @pytest.fixture() -def live_mail_account() -> Generator[MailAccount, None, None]: - try: - account = MailAccount.objects.create( - name="test", - imap_server=os.environ["PAPERLESS_MAIL_TEST_HOST"], - username=os.environ["PAPERLESS_MAIL_TEST_USER"], - password=os.environ["PAPERLESS_MAIL_TEST_PASSWD"], - imap_port=993, - ) - yield account - finally: - account.delete() +def greenmail_mail_account(db: None) -> Generator[MailAccount, None, None]: + """ + Create a mail account configured for local Greenmail server. + """ + account = MailAccount.objects.create( + name="Greenmail Test", + imap_server="localhost", + imap_port=3143, + imap_security=MailAccount.ImapSecurity.NONE, + username="test@localhost", + password="test", + character_set="UTF-8", + ) + yield account + account.delete() @pytest.fixture() diff --git a/src/paperless_mail/tests/test_live_mail.py b/src/paperless_mail/tests/test_live_mail.py index ecf9f73b6..c7dcffadd 100644 --- a/src/paperless_mail/tests/test_live_mail.py +++ b/src/paperless_mail/tests/test_live_mail.py @@ -1,6 +1,3 @@ -import os -import warnings - import pytest from paperless_mail.mail import MailAccountHandler @@ -9,53 +6,51 @@ from paperless_mail.models import MailAccount from paperless_mail.models import MailRule -# Only run if the environment is setup -# And the environment is not empty (forks, I think) -@pytest.mark.skipif( - "PAPERLESS_MAIL_TEST_HOST" not in os.environ - or not len(os.environ["PAPERLESS_MAIL_TEST_HOST"]), - reason="Live server testing not enabled", -) -@pytest.mark.django_db() -class TestMailLiveServer: - def test_process_non_gmail_server_flag( +@pytest.mark.django_db +class TestMailGreenmail: + """ + Mail tests using local Greenmail server + """ + + def test_process_flag( self, mail_account_handler: MailAccountHandler, - live_mail_account: MailAccount, - ): + greenmail_mail_account: MailAccount, + ) -> None: + """ + Test processing mail with FLAG action. + """ + rule = MailRule.objects.create( + name="testrule", + account=greenmail_mail_account, + action=MailRule.MailAction.FLAG, + ) + try: - rule1 = MailRule.objects.create( - name="testrule", - account=live_mail_account, - action=MailRule.MailAction.FLAG, - ) - - mail_account_handler.handle_mail_account(live_mail_account) - - rule1.delete() - + mail_account_handler.handle_mail_account(greenmail_mail_account) except MailError as e: pytest.fail(f"Failure: {e}") - except Exception as e: - warnings.warn(f"Unhandled exception: {e}") + finally: + rule.delete() - def test_process_non_gmail_server_tag( + def test_process_tag( self, mail_account_handler: MailAccountHandler, - live_mail_account: MailAccount, - ): + greenmail_mail_account: MailAccount, + ) -> None: + """ + Test processing mail with TAG action. + """ + rule = MailRule.objects.create( + name="testrule", + account=greenmail_mail_account, + action=MailRule.MailAction.TAG, + action_parameter="TestTag", + ) + try: - rule2 = MailRule.objects.create( - name="testrule", - account=live_mail_account, - action=MailRule.MailAction.TAG, - ) - - mail_account_handler.handle_mail_account(live_mail_account) - - rule2.delete() - + mail_account_handler.handle_mail_account(greenmail_mail_account) except MailError as e: pytest.fail(f"Failure: {e}") - except Exception as e: - warnings.warn(f"Unhandled exception: {e}") + finally: + rule.delete() diff --git a/uv.lock b/uv.lock index da7c721f5..960b5aaa3 100644 --- a/uv.lock +++ b/uv.lock @@ -3152,15 +3152,15 @@ dev = [ { name = "mkdocs-material", specifier = "~=9.7.0" }, { name = "pre-commit", specifier = "~=4.5.1" }, { name = "pre-commit-uv", specifier = "~=4.2.0" }, - { name = "pytest", specifier = "~=8.4.1" }, + { name = "pytest", specifier = "~=9.0.0" }, { name = "pytest-cov", specifier = "~=7.0.0" }, { name = "pytest-django", specifier = "~=4.11.1" }, - { name = "pytest-env" }, + { name = "pytest-env", specifier = "~=1.2.0" }, { name = "pytest-httpx" }, - { name = "pytest-mock" }, - { name = "pytest-rerunfailures" }, + { name = "pytest-mock", specifier = "~=3.15.1" }, + { name = "pytest-rerunfailures", specifier = "~=16.1" }, { name = "pytest-sugar" }, - { name = "pytest-xdist" }, + { name = "pytest-xdist", specifier = "~=3.8.0" }, { name = "ruff", specifier = "~=0.14.0" }, ] docs = [ @@ -3176,15 +3176,15 @@ testing = [ { name = "daphne" }, { name = "factory-boy", specifier = "~=3.3.1" }, { name = "imagehash" }, - { name = "pytest", specifier = "~=8.4.1" }, + { name = "pytest", specifier = "~=9.0.0" }, { name = "pytest-cov", specifier = "~=7.0.0" }, { name = "pytest-django", specifier = "~=4.11.1" }, - { name = "pytest-env" }, + { name = "pytest-env", specifier = "~=1.2.0" }, { name = "pytest-httpx" }, - { name = "pytest-mock" }, - { name = "pytest-rerunfailures" }, + { name = "pytest-mock", specifier = "~=3.15.1" }, + { name = "pytest-rerunfailures", specifier = "~=16.1" }, { name = "pytest-sugar" }, - { name = "pytest-xdist" }, + { name = "pytest-xdist", specifier = "~=3.8.0" }, ] typing = [ { name = "celery-types" }, @@ -3841,7 +3841,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, @@ -3851,9 +3851,9 @@ dependencies = [ { name = "pygments", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] @@ -3897,15 +3897,15 @@ wheels = [ [[package]] name = "pytest-httpx" -version = "0.35.0" +version = "0.36.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, ] [[package]] @@ -5108,13 +5108,13 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp310-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:bf1e68cfb935ae2046374ff02a7aa73dda70351b46342846f557055b3a540bf0" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:a52952a8c90a422c14627ea99b9826b7557203b46b4d0772d3ca5c7699692425" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:287242dd1f830846098b5eca847f817aa5c6015ea57ab4c1287809efea7b77eb" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8924d10d36eac8fe0652a060a03fc2ae52980841850b9a1a2ddb0f27a4f181cd" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:bcee64ae7aa65876ceeae6dcaebe75109485b213528c74939602208a20706e3f" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:defadbeb055cfcf5def58f70937145aecbd7a4bc295238ded1d0e85ae2cf0e1d" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:886f84b181f766f53265ba0a1d503011e60f53fff9d569563ef94f24160e1072" }, ] [[package]] @@ -5138,20 +5138,20 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'linux'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:10866c8a48c4aa5ae3f48538dc8a055b99c57d9c6af2bf5dd715374d9d6ddca3" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7210713b66943fdbfcc237b2e782871b649123ac5d29f548ce8c85be4223ab38" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0e611cfb16724e62252b67d31073bc5c490cb83e92ecdc1192762535e0e44487" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3de2adb9b4443dc9210ef1f1b16da3647ace53553166d6360bbbd7edd6f16e4d" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3bf9b442a51a2948e41216a76d7ab00f0694cfcaaa51b6f9bcab57b7f89843e6" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7417d8c565f219d3455654cb431c6d892a3eb40246055e14d645422de13b9ea1" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3e532e553b37ee859205a9b2d1c7977fd6922f53bbb1b9bfdd5bdc00d1a60ed4" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:39b3dff6d8fba240ae0d1bede4ca11c2531ae3b47329206512d99e17907ff74b" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:01b1884f724977a20c7da2f640f1c7b37f4a2c117a7f4a6c1c0424d14cb86322" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031a597147fa81b1e6d79ccf1ad3ccc7fafa27941d6cf26ff5caaa384fb20e92" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:65010ab4aacce6c9a1ddfc935f986c003ca8638ded04348fd326c3e74346237c" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:88adf5157db5da1d54b1c9fe4a6c1d20ceef00e75d854e206a87dbf69e3037dc" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3ac2b8df2c55430e836dcda31940d47f1f5f94b8731057b6f20300ebea394dd9" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b688445f928f13563b7418b17c57e97bf955ab559cf73cd8f2b961f8572dbb3" }, ] [[package]]