Compare commits

..

31 Commits

Author SHA1 Message Date
shamoon
72df7fb1ee Move migration 2026-01-27 17:03:05 -08:00
shamoon
873a483843 Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-27 17:02:19 -08:00
shamoon
abc7a8a1c0 Move migration 2026-01-26 15:17:19 -08:00
shamoon
359460b3c0 Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-26 15:17:08 -08:00
shamoon
d971cdf004 Rename migration 2026-01-25 17:02:34 -08:00
shamoon
5e62aa64ed Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-25 17:01:54 -08:00
shamoon
bc406d612a Regenerate migration 2026-01-24 20:02:13 -08:00
shamoon
b12936c8c6 Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-24 20:01:32 -08:00
shamoon
f596294e1f Merge branch 'dev' into feature-pw-removal-workflow-action 2026-01-09 20:45:05 -08:00
shamoon
02e590c70c Add hint for plain text password storage 2025-12-30 12:49:02 -08:00
shamoon
3c53e4bab1 Add docstrings to workflow password removal tests 2025-12-30 12:44:01 -08:00
shamoon
880b3e6d15 Better, attempt removal later for ConsumableDocument 2025-12-30 12:44:00 -08:00
shamoon
f7a6f79c8b Update test_workflows.py 2025-12-28 21:45:01 -08:00
shamoon
87dc22fbf6 Update test_workflows.py 2025-12-28 21:41:51 -08:00
shamoon
2332b3f6ad and this 2025-12-28 21:02:02 -08:00
shamoon
5fbc985b67 simplify this 2025-12-28 21:00:06 -08:00
shamoon
7f95160a63 add api tests 2025-12-28 20:58:10 -08:00
shamoon
1aaf128bcb Enhancement: password removal workflow action 2025-12-28 20:05:46 -08:00
shamoon
10db1e6405 Change param order 2025-12-28 16:05:38 -08:00
shamoon
0e2611163b Fix docs 2025-12-28 16:05:38 -08:00
shamoon
b917db44ed Cover this last bit 2025-12-28 16:05:38 -08:00
shamoon
bca409d932 Add password removal confirm dialog, with options 2025-12-28 16:05:38 -08:00
shamoon
07d67b3299 whitespace yay 2025-12-28 16:05:38 -08:00
shamoon
5fca9bac50 Fix formatting issue in document-detail.spec.ts 2025-12-28 16:05:38 -08:00
shamoon
b21df970fd backend test coverage
Added a test for the remove_password function to ensure it deletes the original document when specified.
2025-12-28 16:05:38 -08:00
shamoon
833890d0ca fix frontend test coverage 2025-12-28 16:05:38 -08:00
shamoon
eb1708420e Just hide for non-owners 2025-12-28 16:05:38 -08:00
shamoon
3bb74772a9 Backend coverage 2025-12-28 16:05:38 -08:00
shamoon
402c9af81b Add test 2025-12-28 16:05:38 -08:00
shamoon
c1de78162b Add update_document flag to bulkEdit remove_password 2025-12-28 16:05:38 -08:00
shamoon
f888722a73 Basic remove password bulk edit action 2025-12-28 16:05:38 -08:00
40 changed files with 829 additions and 660 deletions

View File

@@ -46,13 +46,14 @@ jobs:
id: ref
run: |
ref_name="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}"
# Sanitize by replacing / with - for use in tags and cache keys
sanitized_ref="${ref_name//\//-}"
# Sanitize by replacing / with - for cache keys
cache_ref="${ref_name//\//-}"
echo "ref_name=${ref_name}"
echo "sanitized_ref=${sanitized_ref}"
echo "cache_ref=${cache_ref}"
echo "name=${sanitized_ref}" >> $GITHUB_OUTPUT
echo "name=${ref_name}" >> $GITHUB_OUTPUT
echo "cache-ref=${cache_ref}" >> $GITHUB_OUTPUT
- name: Check push permissions
id: check-push
env:
@@ -61,14 +62,12 @@ jobs:
# should-push: Should we push to GHCR?
# True for:
# 1. Pushes (tags/dev/beta) - filtered via the workflow triggers
# 2. Manual dispatch - always push to GHCR
# 3. Internal PRs where the branch name starts with 'feature-' or 'fix-'
# 2. Internal PRs where the branch name starts with 'feature-' - filtered here when a PR is synced
should_push="false"
if [[ "${{ github.event_name }}" == "push" ]]; then
should_push="true"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
should_push="true"
elif [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
if [[ "${REF_NAME}" == feature-* || "${REF_NAME}" == fix-* ]]; then
should_push="true"
@@ -140,9 +139,9 @@ jobs:
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }},push-by-digest=true,name-canonical=true,push=${{ steps.check-push.outputs.should-push }}
cache-from: |
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:${{ steps.ref.outputs.name }}-${{ matrix.arch }}
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:${{ steps.ref.outputs.cache-ref }}-${{ matrix.arch }}
type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:dev-${{ matrix.arch }}
cache-to: ${{ steps.check-push.outputs.should-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.name, matrix.arch) || '' }}
cache-to: ${{ steps.check-push.outputs.should-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.cache-ref, matrix.arch) || '' }}
- name: Export digest
if: steps.check-push.outputs.should-push == 'true'
run: |

View File

@@ -34,13 +34,3 @@ services:
ports:
- "3143:3143" # IMAP
restart: unless-stopped
nginx:
image: docker.io/nginx:1.29-alpine
hostname: nginx
container_name: nginx
ports:
- "8080:8080"
restart: unless-stopped
volumes:
- ../../docs/assets:/usr/share/nginx/html/assets:ro
- ./test-nginx.conf:/etc/nginx/conf.d/default.conf:ro

View File

@@ -1,14 +0,0 @@
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
# Enable CORS for test requests
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, HEAD, OPTIONS' always;
location / {
try_files $uri $uri/ =404;
}
}

View File

@@ -582,7 +582,7 @@ document.
### Detecting duplicates {#fuzzy_duplicate}
Paperless-ngx already catches and warns of exactly matching documents,
Paperless already catches and prevents upload of exactly matching documents,
however a new scan of an existing document may not produce an exact bit for bit
duplicate. But the content should be exact or close, allowing detection.

View File

@@ -16,7 +16,6 @@ classifiers = [
# This will allow testing to not install a webserver, mysql, etc
dependencies = [
"adrf~=0.1.12",
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17",
"bleach~=6.3.0",
@@ -301,14 +300,6 @@ norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings"
markers = [
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
"nginx: Tests that make HTTP requests to the local nginx service",
"gotenberg: Tests requiring Gotenberg service",
"tika: Tests requiring Tika service",
"greenmail: Tests requiring Greenmail service",
]
[tool.pytest_env]
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"

View File

@@ -561,7 +561,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">400</context>
<context context-type="linenumber">386</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
@@ -1201,72 +1201,28 @@
<source>Bulk editing</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">264</context>
<context context-type="linenumber">263</context>
</context-group>
</trans-unit>
<trans-unit id="8158899674926420054" datatype="html">
<source>Show confirmation dialogs</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">267</context>
<context context-type="linenumber">266</context>
</context-group>
</trans-unit>
<trans-unit id="290238406234356122" datatype="html">
<source>Apply on close</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">268</context>
</context-group>
</trans-unit>
<trans-unit id="5084275925647254161" datatype="html">
<source>PDF Editor</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">272</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">66</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1472</context>
</context-group>
</trans-unit>
<trans-unit id="1577733187050997705" datatype="html">
<source>Default editing mode</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">275</context>
</context-group>
</trans-unit>
<trans-unit id="7273640930165035289" datatype="html">
<source>Create new document(s)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">279</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="8035757452478567832" datatype="html">
<source>Update existing document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">280</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">87</context>
<context context-type="linenumber">267</context>
</context-group>
</trans-unit>
<trans-unit id="8104421162933956065" datatype="html">
<source>Notes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">285</context>
<context context-type="linenumber">271</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
@@ -1285,14 +1241,14 @@
<source>Enable notes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">288</context>
<context context-type="linenumber">274</context>
</context-group>
</trans-unit>
<trans-unit id="7314814725704332646" datatype="html">
<source>Permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">297</context>
<context context-type="linenumber">283</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
@@ -1355,28 +1311,28 @@
<source>Default Permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">300</context>
<context context-type="linenumber">286</context>
</context-group>
</trans-unit>
<trans-unit id="6544153565064275581" datatype="html">
<source> Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI. </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">304,306</context>
<context context-type="linenumber">290,292</context>
</context-group>
</trans-unit>
<trans-unit id="4292903881380648974" datatype="html">
<source>Default Owner</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">311</context>
<context context-type="linenumber">297</context>
</context-group>
</trans-unit>
<trans-unit id="734147282056744882" datatype="html">
<source>Objects without an owner can be viewed and edited by all users</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">315</context>
<context context-type="linenumber">301</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@@ -1387,18 +1343,18 @@
<source>Default View Permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">320</context>
<context context-type="linenumber">306</context>
</context-group>
</trans-unit>
<trans-unit id="2191775412581217688" datatype="html">
<source>Users:</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">325</context>
<context context-type="linenumber">311</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">352</context>
<context context-type="linenumber">338</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
@@ -1429,11 +1385,11 @@
<source>Groups:</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">335</context>
<context context-type="linenumber">321</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">362</context>
<context context-type="linenumber">348</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
@@ -1464,14 +1420,14 @@
<source>Default Edit Permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">347</context>
<context context-type="linenumber">333</context>
</context-group>
</trans-unit>
<trans-unit id="3728984448750213892" datatype="html">
<source>Edit permissions also grant viewing permissions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">371</context>
<context context-type="linenumber">357</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
@@ -1490,7 +1446,7 @@
<source>Notifications</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">379</context>
<context context-type="linenumber">365</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/toasts-dropdown/toasts-dropdown.component.html</context>
@@ -1501,49 +1457,49 @@
<source>Document processing</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">382</context>
<context context-type="linenumber">368</context>
</context-group>
</trans-unit>
<trans-unit id="3656786776644872398" datatype="html">
<source>Show notifications when new documents are detected</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">386</context>
<context context-type="linenumber">372</context>
</context-group>
</trans-unit>
<trans-unit id="6057053428592387613" datatype="html">
<source>Show notifications when document processing completes successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">387</context>
<context context-type="linenumber">373</context>
</context-group>
</trans-unit>
<trans-unit id="370315664367425513" datatype="html">
<source>Show notifications when document processing fails</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">388</context>
<context context-type="linenumber">374</context>
</context-group>
</trans-unit>
<trans-unit id="6838309441164918531" datatype="html">
<source>Suppress notifications on dashboard</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">389</context>
<context context-type="linenumber">375</context>
</context-group>
</trans-unit>
<trans-unit id="2741919327232918179" datatype="html">
<source>This will suppress all messages about document processing status on the dashboard.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">389</context>
<context context-type="linenumber">375</context>
</context-group>
</trans-unit>
<trans-unit id="2159130950882492111" datatype="html">
<source>Cancel</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">399</context>
<context context-type="linenumber">385</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
@@ -1614,21 +1570,21 @@
<source>Use system language</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">79</context>
<context context-type="linenumber">78</context>
</context-group>
</trans-unit>
<trans-unit id="7729897675462249787" datatype="html">
<source>Use date format of display language</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">82</context>
<context context-type="linenumber">81</context>
</context-group>
</trans-unit>
<trans-unit id="1379170675585571971" datatype="html">
<source>Archive serial number</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">96</context>
<context context-type="linenumber">95</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1639,7 +1595,7 @@
<source>Correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">98</context>
<context context-type="linenumber">97</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1670,7 +1626,7 @@
<source>Document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">99</context>
<context context-type="linenumber">98</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1701,7 +1657,7 @@
<source>Storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">99</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -1728,7 +1684,7 @@
<source>Tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">101</context>
<context context-type="linenumber">100</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
@@ -1767,7 +1723,7 @@
<source>Error retrieving users</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">252</context>
<context context-type="linenumber">248</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@@ -1778,7 +1734,7 @@
<source>Error retrieving groups</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">271</context>
<context context-type="linenumber">267</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@@ -1789,28 +1745,28 @@
<source>Settings were saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">588</context>
<context context-type="linenumber">577</context>
</context-group>
</trans-unit>
<trans-unit id="525012668859298131" datatype="html">
<source>Settings were saved successfully. Reload is required to apply some changes.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">592</context>
<context context-type="linenumber">581</context>
</context-group>
</trans-unit>
<trans-unit id="8491974984518503778" datatype="html">
<source>Reload now</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">593</context>
<context context-type="linenumber">582</context>
</context-group>
</trans-unit>
<trans-unit id="3011185103048412841" datatype="html">
<source>An error occurred while saving settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
<context context-type="linenumber">603</context>
<context context-type="linenumber">592</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
@@ -2819,11 +2775,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1108</context>
<context context-type="linenumber">1121</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1473</context>
<context context-type="linenumber">1486</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3414,7 +3370,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1061</context>
<context context-type="linenumber">1074</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3519,7 +3475,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1524</context>
<context context-type="linenumber">1537</context>
</context-group>
</trans-unit>
<trans-unit id="6661109599266152398" datatype="html">
@@ -3530,7 +3486,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1525</context>
<context context-type="linenumber">1538</context>
</context-group>
</trans-unit>
<trans-unit id="5162686434580248853" datatype="html">
@@ -3541,7 +3497,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1526</context>
<context context-type="linenumber">1539</context>
</context-group>
</trans-unit>
<trans-unit id="8157388568390631653" datatype="html">
@@ -6056,6 +6012,20 @@
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="7273640930165035289" datatype="html">
<source>Create new document(s)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="8035757452478567832" datatype="html">
<source>Update existing document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/pdf-editor/pdf-editor.component.html</context>
<context context-type="linenumber">87</context>
</context-group>
</trans-unit>
<trans-unit id="7248454234750442816" datatype="html">
<source>Copy metadata</source>
<context-group purpose="location">
@@ -7403,6 +7373,17 @@
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="5084275925647254161" datatype="html">
<source>PDF Editor</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">66</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1485</context>
</context-group>
</trans-unit>
<trans-unit id="2336375155355449543" datatype="html">
<source>Remove Password</source>
<context-group purpose="location">
@@ -7638,56 +7619,56 @@
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">428,430</context>
<context context-type="linenumber">441,443</context>
</context-group>
</trans-unit>
<trans-unit id="3200733026060976258" datatype="html">
<source>Document changes detected</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">467</context>
<context context-type="linenumber">480</context>
</context-group>
</trans-unit>
<trans-unit id="2887155916749964" datatype="html">
<source>The version of this document in your browser session appears older than the existing version.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">468</context>
<context context-type="linenumber">481</context>
</context-group>
</trans-unit>
<trans-unit id="237142428785956348" datatype="html">
<source>Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">469</context>
<context context-type="linenumber">482</context>
</context-group>
</trans-unit>
<trans-unit id="8720977247725652816" datatype="html">
<source>Ok</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">471</context>
<context context-type="linenumber">484</context>
</context-group>
</trans-unit>
<trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">597</context>
<context context-type="linenumber">610</context>
</context-group>
</trans-unit>
<trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">607</context>
<context context-type="linenumber">620</context>
</context-group>
</trans-unit>
<trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">615</context>
<context context-type="linenumber">628</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
@@ -7698,67 +7679,67 @@
<source>Save document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">622</context>
<context context-type="linenumber">635</context>
</context-group>
</trans-unit>
<trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">631</context>
<context context-type="linenumber">644</context>
</context-group>
</trans-unit>
<trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">686</context>
<context context-type="linenumber">699</context>
</context-group>
</trans-unit>
<trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">741</context>
<context context-type="linenumber">754</context>
</context-group>
</trans-unit>
<trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">950</context>
<context context-type="linenumber">963</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">974</context>
<context context-type="linenumber">987</context>
</context-group>
</trans-unit>
<trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">980</context>
<context context-type="linenumber">993</context>
</context-group>
</trans-unit>
<trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1030</context>
<context context-type="linenumber">1043</context>
</context-group>
</trans-unit>
<trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1062</context>
<context context-type="linenumber">1075</context>
</context-group>
</trans-unit>
<trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1063</context>
<context context-type="linenumber">1076</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7769,7 +7750,7 @@
<source>Move to trash</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1065</context>
<context context-type="linenumber">1078</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7780,14 +7761,14 @@
<source>Error deleting document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1084</context>
<context context-type="linenumber">1097</context>
</context-group>
</trans-unit>
<trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1104</context>
<context context-type="linenumber">1117</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7798,102 +7779,102 @@
<source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1105</context>
<context context-type="linenumber">1118</context>
</context-group>
</trans-unit>
<trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1106</context>
<context context-type="linenumber">1119</context>
</context-group>
</trans-unit>
<trans-unit id="8251197608401006898" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1116</context>
<context context-type="linenumber">1129</context>
</context-group>
</trans-unit>
<trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1127</context>
<context context-type="linenumber">1140</context>
</context-group>
</trans-unit>
<trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1176</context>
<context context-type="linenumber">1189</context>
</context-group>
</trans-unit>
<trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1253</context>
<context context-type="linenumber">1266</context>
</context-group>
</trans-unit>
<trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1491</context>
<context context-type="linenumber">1504</context>
</context-group>
</trans-unit>
<trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1503</context>
<context context-type="linenumber">1516</context>
</context-group>
</trans-unit>
<trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1514</context>
<context context-type="linenumber">1527</context>
</context-group>
</trans-unit>
<trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1546</context>
<context context-type="linenumber">1559</context>
</context-group>
</trans-unit>
<trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1560</context>
<context context-type="linenumber">1573</context>
</context-group>
</trans-unit>
<trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1597</context>
<context context-type="linenumber">1610</context>
</context-group>
</trans-unit>
<trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1609</context>
<context context-type="linenumber">1622</context>
</context-group>
</trans-unit>
<trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1674</context>
<context context-type="linenumber">1687</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1678</context>
<context context-type="linenumber">1691</context>
</context-group>
</trans-unit>
<trans-unit id="4958946940233632319" datatype="html">

View File

@@ -259,7 +259,6 @@
</div>
</div>
</div>
<div class="col-xl-6 ps-xl-5">
<h5 class="mt-3" i18n>Bulk editing</h5>
<div class="row mb-3">
@@ -269,19 +268,6 @@
</div>
</div>
<h5 class="mt-3" i18n>PDF Editor</h5>
<div class="row">
<div class="col-md-3 col-form-label pt-0">
<span i18n>Default editing mode</span>
</div>
<div class="col">
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Update existing document</option>
</select>
</div>
</div>
<h5 class="mt-3" i18n>Notes</h5>
<div class="row mb-3">
<div class="col">

View File

@@ -251,7 +251,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(32)
expect(setSpy).toHaveBeenCalledTimes(31)
// succeed
storeSpy.mockReturnValueOnce(of(true))

View File

@@ -64,9 +64,8 @@ import { PermissionsGroupComponent } from '../../common/input/permissions/permis
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { ZoomSetting } from '../../document-detail/zoom-setting'
import { ZoomSetting } from '../../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs {
@@ -164,7 +163,6 @@ export class SettingsComponent
defaultPermsEditGroups: new FormControl(null),
useNativePdfViewer: new FormControl(null),
pdfViewerDefaultZoom: new FormControl(null),
pdfEditorDefaultEditMode: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null),
documentDetailsHiddenFields: new FormControl([]),
@@ -198,8 +196,6 @@ export class SettingsComponent
public readonly ZoomSetting = ZoomSetting
public readonly PdfEditorEditMode = PdfEditorEditMode
public readonly documentDetailFieldOptions = documentDetailFieldOptions
get systemStatusHasErrors(): boolean {
@@ -318,9 +314,6 @@ export class SettingsComponent
pdfViewerDefaultZoom: this.settings.get(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING
),
pdfEditorDefaultEditMode: this.settings.get(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
),
displayLanguage: this.settings.getLanguage(),
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
@@ -490,10 +483,6 @@ export class SettingsComponent
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
this.settingsForm.value.pdfViewerDefaultZoom
)
this.settings.set(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE,
this.settingsForm.value.pdfEditorDefaultEditMode
)
this.settings.set(
SETTINGS_KEYS.DATE_LOCALE,
this.settingsForm.value.dateLocale

View File

@@ -248,7 +248,7 @@ main {
}
}
@media screen and (min-width: 376px) and (max-width: 768px) {
@media screen and (min-width: 366px) and (max-width: 768px) {
.navbar-toggler {
// compensate for 2 buttons on the right
margin-right: 45px;

View File

@@ -430,6 +430,24 @@
</div>
</div>
}
@case (WorkflowActionType.PasswordRemoval) {
<div class="row">
<div class="col">
<p class="small" i18n>
One or more passwords separated by commas or new lines. The workflow will try them in order until one succeeds.
</p>
<pngx-input-textarea
i18n-title
title="Passwords"
formControlName="passwords"
rows="4"
[error]="error?.actions?.[i]?.passwords"
hint="Passwords are stored in plain text. Use with caution."
i18n-hint
></pngx-input-textarea>
</div>
</div>
}
}
</div>
</ng-template>

View File

@@ -139,6 +139,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.Webhook,
name: $localize`Webhook`,
},
{
id: WorkflowActionType.PasswordRemoval,
name: $localize`Password removal`,
},
]
export enum TriggerFilterType {
@@ -1202,6 +1206,7 @@ export class WorkflowEditDialogComponent
headers: new FormControl(action.webhook?.headers),
include_document: new FormControl(!!action.webhook?.include_document),
}),
passwords: new FormControl(action.passwords),
}),
{ emitEvent }
)

View File

@@ -1,4 +0,0 @@
export enum PdfEditorEditMode {
Update = 'update',
Create = 'create',
}

View File

@@ -8,11 +8,8 @@ import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
import { PdfEditorEditMode } from './pdf-editor-edit-mode'
interface PageOperation {
page: number
@@ -22,6 +19,11 @@ interface PageOperation {
loaded?: boolean
}
export enum PdfEditorEditMode {
Update = 'update',
Create = 'create',
}
@Component({
selector: 'pngx-pdf-editor',
templateUrl: './pdf-editor.component.html',
@@ -37,15 +39,12 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
public PdfEditorEditMode = PdfEditorEditMode
private documentService = inject(DocumentService)
private readonly settingsService = inject(SettingsService)
activeModal: NgbActiveModal = inject(NgbActiveModal)
documentID: number
pages: PageOperation[] = []
totalPages = 0
editMode: PdfEditorEditMode = this.settingsService.get(
SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE
)
editMode: PdfEditorEditMode = PdfEditorEditMode.Create
deleteOriginal: boolean = false
includeMetadata: boolean = true

View File

@@ -69,8 +69,10 @@ import { environment } from 'src/environments/environment'
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'
import { DocumentDetailComponent } from './document-detail.component'
import { ZoomSetting } from './zoom-setting'
import {
DocumentDetailComponent,
ZoomSetting,
} from './document-detail.component'
const doc: Document = {
id: 3,

View File

@@ -106,15 +106,16 @@ import { TextComponent } from '../common/input/text/text.component'
import { TextAreaComponent } from '../common/input/textarea/textarea.component'
import { UrlComponent } from '../common/input/url/url.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
import {
PDFEditorComponent,
PdfEditorEditMode,
} from '../common/pdf-editor/pdf-editor.component'
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
import { ZoomSetting } from './zoom-setting'
enum DocumentDetailNavIDs {
Details = 1,
@@ -136,6 +137,18 @@ enum ContentRenderType {
TIFF = 'tiff',
}
export enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}
@Component({
selector: 'pngx-document-detail',
templateUrl: './document-detail.component.html',
@@ -170,6 +183,7 @@ enum ContentRenderType {
NgxBootstrapIconsModule,
PdfViewerModule,
TextAreaComponent,
PasswordRemovalConfirmDialogComponent,
RouterModule,
],
})

View File

@@ -1,11 +0,0 @@
export enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
Half = '.5',
ThreeQuarters = '.75',
One = '1',
OneAndHalf = '1.5',
Two = '2',
Three = '3',
}

View File

@@ -1,5 +1,3 @@
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
import { ZoomSetting } from '../components/document-detail/zoom-setting'
import { User } from './user'
export interface UiSettings {
@@ -76,8 +74,6 @@ export const SETTINGS_KEYS = {
'general-settings:document-details:hidden-fields',
SEARCH_DB_ONLY: 'general-settings:search:db-only',
SEARCH_FULL_TYPE: 'general-settings:search:more-link',
PDF_EDITOR_DEFAULT_EDIT_MODE:
'general-settings:document-editing:default-edit-mode',
EMPTY_TRASH_DELAY: 'trash_delay',
GMAIL_OAUTH_URL: 'gmail_oauth_url',
OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
@@ -299,16 +295,11 @@ export const SETTINGS: UiSetting[] = [
{
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
type: 'string',
default: ZoomSetting.PageWidth,
default: 'page-width', // ZoomSetting from 'document-detail.component'
},
{
key: SETTINGS_KEYS.AI_ENABLED,
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.PDF_EDITOR_DEFAULT_EDIT_MODE,
type: 'string',
default: PdfEditorEditMode.Create,
},
]

View File

@@ -5,6 +5,7 @@ export enum WorkflowActionType {
Removal = 2,
Email = 3,
Webhook = 4,
PasswordRemoval = 5,
}
export interface WorkflowActionEmail extends ObjectWithId {
@@ -97,4 +98,6 @@ export interface WorkflowAction extends ObjectWithId {
email?: WorkflowActionEmail
webhook?: WorkflowActionWebhook
passwords?: string
}

View File

@@ -1,41 +1,30 @@
import {
HttpClient,
provideHttpClient,
withInterceptors,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { HttpEvent, HttpRequest } from '@angular/common/http'
import { TestBed } from '@angular/core/testing'
import { of } from 'rxjs'
import { environment } from 'src/environments/environment'
import { withApiVersionInterceptor } from './api-version.interceptor'
import { ApiVersionInterceptor } from './api-version.interceptor'
describe('ApiVersionInterceptor', () => {
let httpClient: HttpClient
let httpMock: HttpTestingController
let interceptor: ApiVersionInterceptor
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([withApiVersionInterceptor])),
provideHttpClientTesting(),
],
providers: [ApiVersionInterceptor],
})
httpClient = TestBed.inject(HttpClient)
httpMock = TestBed.inject(HttpTestingController)
interceptor = TestBed.inject(ApiVersionInterceptor)
})
it('should add api version to headers', () => {
httpClient.get('https://example.com').subscribe()
const request = httpMock.expectOne('https://example.com')
const header = request.request.headers['lazyUpdate'][0]
expect(header.name).toEqual('Accept')
expect(header.value).toEqual(
`application/json; version=${environment.apiVersion}`
)
request.flush({})
interceptor.intercept(new HttpRequest('GET', 'https://example.com'), {
handle: (request) => {
const header = request.headers['lazyUpdate'][0]
expect(header.name).toEqual('Accept')
expect(header.value).toEqual(
`application/json; version=${environment.apiVersion}`
)
return of({} as HttpEvent<any>)
},
})
})
})

View File

@@ -1,20 +1,27 @@
import {
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { environment } from 'src/environments/environment'
export const withApiVersionInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
request = request.clone({
setHeaders: {
Accept: `application/json; version=${environment.apiVersion}`,
},
})
return next(request)
@Injectable()
export class ApiVersionInterceptor implements HttpInterceptor {
constructor() {}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
request = request.clone({
setHeaders: {
Accept: `application/json; version=${environment.apiVersion}`,
},
})
return next.handle(request)
}
}

View File

@@ -1,52 +1,35 @@
import {
HttpClient,
provideHttpClient,
withInterceptors,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { HttpEvent, HttpRequest } from '@angular/common/http'
import { TestBed } from '@angular/core/testing'
import { Meta } from '@angular/platform-browser'
import { CookieService } from 'ngx-cookie-service'
import { withCsrfInterceptor } from './csrf.interceptor'
import { of } from 'rxjs'
import { CsrfInterceptor } from './csrf.interceptor'
describe('CsrfInterceptor', () => {
let interceptor: CsrfInterceptor
let meta: Meta
let cookieService: CookieService
let httpClient: HttpClient
let httpMock: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
Meta,
CookieService,
provideHttpClient(withInterceptors([withCsrfInterceptor])),
provideHttpClientTesting(),
],
providers: [CsrfInterceptor, Meta, CookieService],
})
meta = TestBed.inject(Meta)
cookieService = TestBed.inject(CookieService)
httpClient = TestBed.inject(HttpClient)
httpMock = TestBed.inject(HttpTestingController)
interceptor = TestBed.inject(CsrfInterceptor)
})
it('should get csrf token', () => {
meta.addTag({ name: 'cookie_prefix', content: 'ngx-' }, true)
const cookieServiceSpy = jest.spyOn(cookieService, 'get')
cookieServiceSpy.mockReturnValue('csrftoken')
httpClient.get('https://example.com').subscribe()
const request = httpMock.expectOne('https://example.com')
expect(request.request.headers['lazyUpdate'][0]['name']).toEqual(
'X-CSRFToken'
)
interceptor.intercept(new HttpRequest('GET', 'https://example.com'), {
handle: (request) => {
expect(request.headers['lazyUpdate'][0]['name']).toEqual('X-CSRFToken')
return of({} as HttpEvent<any>)
},
})
expect(cookieServiceSpy).toHaveBeenCalled()
request.flush({})
})
})

View File

@@ -1,32 +1,36 @@
import {
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpHandler,
HttpInterceptor,
HttpRequest,
} from '@angular/common/http'
import { inject } from '@angular/core'
import { inject, Injectable } from '@angular/core'
import { Meta } from '@angular/platform-browser'
import { CookieService } from 'ngx-cookie-service'
import { Observable } from 'rxjs'
export const withCsrfInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const cookieService: CookieService = inject(CookieService)
const meta: Meta = inject(Meta)
@Injectable()
export class CsrfInterceptor implements HttpInterceptor {
private cookieService: CookieService = inject(CookieService)
private meta: Meta = inject(Meta)
let prefix = ''
if (meta.getTag('name=cookie_prefix')) {
prefix = meta.getTag('name=cookie_prefix').content
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
let prefix = ''
if (this.meta.getTag('name=cookie_prefix')) {
prefix = this.meta.getTag('name=cookie_prefix').content
}
let csrfToken = this.cookieService.get(`${prefix}csrftoken`)
if (csrfToken) {
request = request.clone({
setHeaders: {
'X-CSRFToken': csrfToken,
},
})
}
return next.handle(request)
}
let csrfToken = cookieService.get(`${prefix}csrftoken`)
if (csrfToken) {
request = request.clone({
setHeaders: {
'X-CSRFToken': csrfToken,
},
})
}
return next(request)
}

View File

@@ -1,16 +1,16 @@
import {
APP_INITIALIZER,
enableProdMode,
importProvidersFrom,
inject,
provideAppInitializer,
provideZoneChangeDetection,
} from '@angular/core'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { DatePipe, registerLocaleData } from '@angular/common'
import {
HTTP_INTERCEPTORS,
provideHttpClient,
withFetch,
withInterceptors,
withInterceptorsFromDi,
} from '@angular/common/http'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -151,14 +151,15 @@ import { AppComponent } from './app/app.component'
import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
import { PermissionsGuard } from './app/guards/permissions.guard'
import { withApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
import { withCsrfInterceptor } from './app/interceptors/csrf.interceptor'
import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
import { CsrfInterceptor } from './app/interceptors/csrf.interceptor'
import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
import { FilterPipe } from './app/pipes/filter.pipe'
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'
@@ -236,11 +237,11 @@ registerLocaleData(localeUk)
registerLocaleData(localeZh)
registerLocaleData(localeZhHant)
function initializeApp() {
const settings = inject(SettingsService)
return settings.initializeSettings()
function initializeApp(settings: SettingsService) {
return () => {
return settings.initializeSettings()
}
}
const icons = {
airplane,
archive,
@@ -362,6 +363,10 @@ const icons = {
xLg,
}
if (environment.production) {
enableProdMode()
}
bootstrapApplication(AppComponent, {
providers: [
provideZoneChangeDetection(),
@@ -378,9 +383,24 @@ bootstrapApplication(AppComponent, {
DragDropModule,
NgxBootstrapIconsModule.pick(icons)
),
provideAppInitializer(initializeApp),
{
provide: APP_INITIALIZER,
useFactory: initializeApp,
deps: [SettingsService],
multi: true,
},
DatePipe,
CookieService,
{
provide: HTTP_INTERCEPTORS,
useClass: CsrfInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: ApiVersionInterceptor,
multi: true,
},
FilterPipe,
DocumentTitlePipe,
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
@@ -392,10 +412,6 @@ bootstrapApplication(AppComponent, {
CorrespondentNamePipe,
DocumentTypeNamePipe,
StoragePathNamePipe,
provideHttpClient(
withInterceptorsFromDi(),
withInterceptors([withCsrfInterceptor, withApiVersionInterceptor]),
withFetch()
),
provideHttpClient(withInterceptorsFromDi(), withFetch()),
],
}).catch((err) => console.error(err))

View File

@@ -501,22 +501,9 @@ class Command(BaseCommand):
stability_timeout_ms = int(stability_delay * 1000)
testing_timeout_ms = int(self.testing_timeout_s * 1000)
# Calculate appropriate timeout for watch loop
# In polling mode, rust_timeout must be significantly longer than poll_delay_ms
# to ensure poll cycles can complete before timing out
if is_testing:
if use_polling:
# For polling: timeout must be at least 3x the poll interval to allow
# multiple poll cycles. This prevents timeouts from interfering with
# the polling mechanism.
min_polling_timeout_ms = poll_delay_ms * 3
timeout_ms = max(min_polling_timeout_ms, testing_timeout_ms)
else:
# For native watching, use short timeout to check stop flag
timeout_ms = testing_timeout_ms
else:
# Not testing, wait indefinitely for first event
timeout_ms = 0
# Start with no timeout (wait indefinitely for first event)
# unless in testing mode
timeout_ms = testing_timeout_ms if is_testing else 0
self.stop_flag.clear()
@@ -556,14 +543,8 @@ class Command(BaseCommand):
# Check pending files at stability interval
timeout_ms = stability_timeout_ms
elif is_testing:
# In testing, use appropriate timeout based on watch mode
if use_polling:
# For polling: ensure timeout allows polls to complete
min_polling_timeout_ms = poll_delay_ms * 3
timeout_ms = max(min_polling_timeout_ms, testing_timeout_ms)
else:
# For native watching, use short timeout to check stop flag
timeout_ms = testing_timeout_ms
# In testing, use short timeout to check stop flag
timeout_ms = testing_timeout_ms
else: # pragma: nocover
# No pending files, wait indefinitely
timeout_ms = 0

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2025-12-29 03:56
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0008_sharelinkbundle"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="passwords",
field=models.TextField(
blank=True,
help_text="Passwords to try when removing PDF protection. Separate with commas or new lines.",
null=True,
verbose_name="passwords",
),
),
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
]

View File

@@ -1405,6 +1405,10 @@ class WorkflowAction(models.Model):
4,
_("Webhook"),
)
PASSWORD_REMOVAL = (
5,
_("Password removal"),
)
type = models.PositiveIntegerField(
_("Workflow Action Type"),
@@ -1634,6 +1638,15 @@ class WorkflowAction(models.Model):
verbose_name=_("webhook"),
)
passwords = models.TextField(
_("passwords"),
null=True,
blank=True,
help_text=_(
"Passwords to try when removing PDF protection. Separate with commas or new lines.",
),
)
class Meta:
verbose_name = _("workflow action")
verbose_name_plural = _("workflow actions")

View File

@@ -2613,6 +2613,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"remove_change_groups",
"email",
"webhook",
"passwords",
]
def validate(self, attrs):
@@ -2669,6 +2670,20 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"Webhook data is required for webhook actions",
)
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
):
passwords = attrs.get("passwords")
if passwords is None or not isinstance(passwords, str):
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
if not passwords.strip():
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
return attrs

View File

@@ -48,6 +48,7 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.templating.utils import convert_format_str_to_template_format
from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action
from documents.workflows.actions import execute_password_removal_action
from documents.workflows.actions import execute_webhook_action
from documents.workflows.mutations import apply_assignment_to_document
from documents.workflows.mutations import apply_assignment_to_overrides
@@ -822,6 +823,8 @@ def run_workflows(
logging_group,
original_file,
)
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
execute_password_removal_action(action, document, logging_group)
if not use_overrides:
# limit title to 128 characters

View File

@@ -838,3 +838,57 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.action.refresh_from_db()
self.assertEqual(self.action.assign_title, "Patched Title")
def test_password_action_passwords_field(self):
"""
GIVEN:
- Nothing
WHEN:
- A workflow password removal action is created with passwords set
THEN:
- The passwords field is correctly stored and retrieved
"""
passwords = "password1,password2\npassword3"
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": passwords,
},
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["passwords"], passwords)
def test_password_action_no_passwords_field(self):
"""
GIVEN:
- Nothing
WHEN:
- A workflow password removal action is created with no passwords set
- A workflow password removal action is created with passwords set to empty string
THEN:
- The required validation error is raised
"""
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Passwords are required",
str(response.data["non_field_errors"][0]),
)
response = self.client.post(
"/api/workflow_actions/",
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": "",
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Passwords are required",
str(response.data["non_field_errors"][0]),
)

View File

@@ -114,30 +114,6 @@ def mock_supported_extensions(mocker: MockerFixture) -> MagicMock:
)
def wait_for_mock_call(
mock_obj: MagicMock,
timeout_s: float = 5.0,
poll_interval_s: float = 0.1,
) -> bool:
"""
Actively wait for a mock to be called.
Args:
mock_obj: The mock object to check (e.g., mock.delay)
timeout_s: Maximum time to wait in seconds
poll_interval_s: How often to check in seconds
Returns:
True if mock was called within timeout, False otherwise
"""
start_time = monotonic()
while monotonic() - start_time < timeout_s:
if mock_obj.called:
return True
sleep(poll_interval_s)
return False
class TestTrackedFile:
"""Tests for the TrackedFile dataclass."""
@@ -748,7 +724,7 @@ def start_consumer(
thread = ConsumerThread(consumption_dir, scratch_dir, **kwargs)
threads.append(thread)
thread.start()
sleep(2.0) # Give thread time to start
sleep(0.5) # Give thread time to start
return thread
try:
@@ -791,8 +767,7 @@ class TestCommandWatch:
target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
sleep(0.5)
if thread.exception:
raise thread.exception
@@ -813,12 +788,9 @@ class TestCommandWatch:
thread = start_consumer()
sleep(0.5)
target = consumption_dir / "document.pdf"
shutil.move(temp_location, target)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
sleep(0.5)
if thread.exception:
raise thread.exception
@@ -844,7 +816,7 @@ class TestCommandWatch:
f.flush()
sleep(0.05)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
sleep(0.5)
if thread.exception:
raise thread.exception
@@ -865,7 +837,7 @@ class TestCommandWatch:
(consumption_dir / "._document.pdf").write_bytes(b"test")
shutil.copy(sample_pdf, consumption_dir / "valid.pdf")
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
sleep(0.5)
if thread.exception:
raise thread.exception
@@ -896,10 +868,11 @@ class TestCommandWatch:
assert not thread.is_alive()
@pytest.mark.django_db
class TestCommandWatchPolling:
"""Tests for polling mode."""
@pytest.mark.django_db
@pytest.mark.flaky(reruns=2)
def test_polling_mode_works(
self,
consumption_dir: Path,
@@ -909,8 +882,7 @@ class TestCommandWatchPolling:
) -> None:
"""
Test polling mode detects files.
Uses active waiting with timeout to handle CI delays and polling timing.
Note: At times, there appears to be a timing issue, where delay has not yet been called, hence this is marked as flaky.
"""
# Use shorter polling interval for faster test
thread = start_consumer(polling_interval=0.5, stability_delay=0.1)
@@ -918,9 +890,9 @@ class TestCommandWatchPolling:
target = consumption_dir / "document.pdf"
shutil.copy(sample_pdf, target)
# Actively wait for consumption
# Polling needs: interval (0.5s) + stability (0.1s) + next poll (0.5s) + margin
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=5.0)
# Wait for: poll interval + stability delay + another poll + margin
# CI can be slow, so use generous timeout
sleep(3.0)
if thread.exception:
raise thread.exception
@@ -947,8 +919,7 @@ class TestCommandWatchRecursive:
target = subdir / "document.pdf"
shutil.copy(sample_pdf, target)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
sleep(0.5)
if thread.exception:
raise thread.exception
@@ -977,8 +948,7 @@ class TestCommandWatchRecursive:
target = subdir / "document.pdf"
shutil.copy(sample_pdf, target)
wait_for_mock_call(mock_consume_file_delay.delay, timeout_s=2.0)
sleep(0.5)
if thread.exception:
raise thread.exception

View File

@@ -2,6 +2,7 @@ import datetime
import json
import shutil
import socket
import tempfile
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
@@ -60,6 +61,7 @@ from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin
from documents.tests.utils import SampleDirMixin
from documents.workflows.actions import execute_password_removal_action
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
@@ -3716,6 +3718,196 @@ class TestWorkflows(
mock_post.assert_called_once()
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_attempts_multiple_passwords(
self,
mock_remove_password,
):
"""
GIVEN:
- Workflow password removal action
- Multiple passwords provided
WHEN:
- Document updated triggering the workflow
THEN:
- Password removal is attempted until one succeeds
"""
doc = Document.objects.create(
title="Protected",
checksum="pw-checksum",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords="wrong, right\n extra ",
)
workflow = Workflow.objects.create(name="Password workflow")
workflow.triggers.add(trigger)
workflow.actions.add(action)
mock_remove_password.side_effect = [
ValueError("wrong password"),
"OK",
]
run_workflows(trigger.type, doc)
assert mock_remove_password.call_count == 2
mock_remove_password.assert_has_calls(
[
mock.call(
[doc.id],
password="wrong",
update_document=True,
user=doc.owner,
),
mock.call(
[doc.id],
password="right",
update_document=True,
user=doc.owner,
),
],
)
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_fails_without_correct_password(
self,
mock_remove_password,
):
"""
GIVEN:
- Workflow password removal action
- No correct password provided
WHEN:
- Document updated triggering the workflow
THEN:
- Password removal is attempted for all passwords and fails
"""
doc = Document.objects.create(
title="Protected",
checksum="pw-checksum-2",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords=" \n , ",
)
workflow = Workflow.objects.create(name="Password workflow missing passwords")
workflow.triggers.add(trigger)
workflow.actions.add(action)
run_workflows(trigger.type, doc)
mock_remove_password.assert_not_called()
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_skips_without_passwords(
self,
mock_remove_password,
):
"""
GIVEN:
- Workflow password removal action with no passwords
WHEN:
- Workflow is run
THEN:
- Password removal is not attempted
"""
doc = Document.objects.create(
title="Protected",
checksum="pw-checksum-2",
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords="",
)
workflow = Workflow.objects.create(name="Password workflow missing passwords")
workflow.triggers.add(trigger)
workflow.actions.add(action)
run_workflows(trigger.type, doc)
mock_remove_password.assert_not_called()
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_consumable_document_deferred(
self,
mock_remove_password,
):
"""
GIVEN:
- Workflow password removal action
- Simulated consumption trigger (a ConsumableDocument is used)
WHEN:
- Document consumption is finished
THEN:
- Password removal is attempted
"""
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
passwords="first, second",
)
temp_dir = Path(tempfile.mkdtemp())
original_file = temp_dir / "file.pdf"
original_file.write_bytes(b"pdf content")
consumable = ConsumableDocument(
source=DocumentSource.ApiUpload,
original_file=original_file,
)
execute_password_removal_action(action, consumable, logging_group=None)
mock_remove_password.assert_not_called()
mock_remove_password.side_effect = [
ValueError("bad password"),
"OK",
]
doc = Document.objects.create(
checksum="pw-checksum-consumed",
title="Protected",
)
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
assert mock_remove_password.call_count == 2
mock_remove_password.assert_has_calls(
[
mock.call(
[doc.id],
password="first",
update_document=True,
user=doc.owner,
),
mock.call(
[doc.id],
password="second",
update_document=True,
user=doc.owner,
),
],
)
# ensure handler disconnected after first run
document_consumption_finished.send(
sender=self.__class__,
document=doc,
)
assert mock_remove_password.call_count == 2
class TestWebhookSend:
def test_send_webhook_data_or_json(

View File

@@ -1,4 +1,5 @@
import logging
import re
from pathlib import Path
from django.conf import settings
@@ -14,6 +15,7 @@ from documents.models import Document
from documents.models import DocumentType
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.signals import document_consumption_finished
from documents.templating.workflows import parse_w_workflow_placeholders
from documents.workflows.webhooks import send_webhook
@@ -265,3 +267,74 @@ def execute_webhook_action(
f"Error occurred sending webhook: {e}",
extra={"group": logging_group},
)
def execute_password_removal_action(
action: WorkflowAction,
document: Document | ConsumableDocument,
logging_group,
) -> None:
"""
Try to remove a password from a document using the configured list.
"""
passwords = action.passwords
if not passwords:
logger.warning(
"Password removal action %s has no passwords configured",
action.pk,
extra={"group": logging_group},
)
return
passwords = [
password.strip()
for password in re.split(r"[,\n]", passwords)
if password.strip()
]
if isinstance(document, ConsumableDocument):
# hook the consumption-finished signal to attempt password removal later
def handler(sender, **kwargs):
consumed_document: Document = kwargs.get("document")
if consumed_document is not None:
execute_password_removal_action(
action,
consumed_document,
logging_group,
)
document_consumption_finished.disconnect(handler)
document_consumption_finished.connect(handler, weak=False)
return
# import here to avoid circular dependency
from documents.bulk_edit import remove_password
for password in passwords:
try:
remove_password(
[document.id],
password=password,
update_document=True,
user=document.owner,
)
logger.info(
"Removed password from document %s using workflow action %s",
document.pk,
action.pk,
extra={"group": logging_group},
)
return
except ValueError as e:
logger.warning(
"Password removal failed for document %s with supplied password: %s",
document.pk,
e,
extra={"group": logging_group},
)
logger.error(
"Password removal failed for document %s after trying all provided passwords",
document.pk,
extra={"group": logging_group},
)

View File

@@ -89,11 +89,3 @@ def greenmail_mail_account(db: None) -> Generator[MailAccount, None, None]:
@pytest.fixture()
def mail_account_handler() -> MailAccountHandler:
return MailAccountHandler()
@pytest.fixture(scope="session")
def nginx_base_url() -> Generator[str, None, None]:
"""
The base URL for the nginx HTTP server we expect to be alive
"""
yield "http://localhost:8080"

View File

@@ -55,7 +55,7 @@ Content-Transfer-Encoding: 7bit
<p>Some Text</p>
<p>
<img src="cid:part1.pNdUSz0s.D3NqVtPg@example.de" alt="Has to be rewritten to work..">
<img src="http://localhost:8080/assets/logo_full_white.svg" alt="This image should not be shown.">
<img src="https://docs.paperless-ngx.com/assets/logo_full_white.svg" alt="This image should not be shown.">
</p>
<p>and an embedded image.<br>

View File

@@ -6,7 +6,7 @@
<p>Some Text</p>
<p>
<img src="cid:part1.pNdUSz0s.D3NqVtPg@example.de" alt="Has to be rewritten to work..">
<img src="http://localhost:8080/assets/logo_full_white.svg" alt="This image should not be shown.">
<img src="https://docs.paperless-ngx.com/assets/logo_full_white.svg" alt="This image should not be shown.">
</p>
<p>and an embedded image.<br>

View File

@@ -6,8 +6,6 @@ from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
@pytest.mark.live
@pytest.mark.greenmail
@pytest.mark.django_db
class TestMailGreenmail:
"""

View File

@@ -17,7 +17,7 @@ from paperless_mail.parsers import MailDocumentParser
def extract_text(pdf_path: Path) -> str:
"""
Using pdftotext from poppler, extracts the text of a PDF into a file,
then reads the file contents and returns it.
then reads the file contents and returns it
"""
with tempfile.NamedTemporaryFile(
mode="w+",
@@ -38,107 +38,71 @@ def extract_text(pdf_path: Path) -> str:
class MailAttachmentMock:
def __init__(self, payload: bytes, content_id: str) -> None:
def __init__(self, payload, content_id):
self.payload = payload
self.content_id = content_id
self.content_type = "image/png"
@pytest.mark.live
@pytest.mark.nginx
@pytest.mark.skipif(
"PAPERLESS_CI_TEST" not in os.environ,
reason="No Gotenberg/Tika servers to test with",
)
class TestNginxService:
class TestUrlCanary:
"""
Verify the local nginx server is responding correctly.
These tests validate that the test infrastructure is working properly
before running the actual parser tests that depend on HTTP resources.
Verify certain URLs are still available so testing is valid still
"""
def test_non_existent_resource_returns_404(
self,
nginx_base_url: str,
) -> None:
def test_online_image_exception_on_not_available(self):
"""
GIVEN:
- Local nginx server is running
- Fresh start
WHEN:
- A non-existent resource is requested
- nonexistent image is requested
THEN:
- An HTTP 404 status code shall be returned
- An exception shall be thrown
"""
"""
A public image is used in the html sample file. We have no control
whether this image stays online forever, so here we check if we can detect if is not
available anymore.
"""
resp = httpx.get(
f"{nginx_base_url}/assets/non-existent.png",
timeout=5.0,
"https://docs.paperless-ngx.com/assets/non-existent.png",
)
with pytest.raises(httpx.HTTPStatusError) as exec_info:
resp.raise_for_status()
assert exec_info.value.response.status_code == httpx.codes.NOT_FOUND
def test_valid_resource_is_available(
self,
nginx_base_url: str,
) -> None:
def test_is_online_image_still_available(self):
"""
GIVEN:
- Local nginx server is running
- Fresh start
WHEN:
- A valid test fixture resource is requested
- A public image used in the html sample file is requested
THEN:
- The resource shall be returned with HTTP 200 status code
- The response shall contain the expected content type
- No exception shall be thrown
"""
"""
A public image is used in the html sample file. We have no control
whether this image stays online forever, so here we check if it is still there
"""
# Now check the URL used in samples/sample.html
resp = httpx.get(
f"{nginx_base_url}/assets/logo_full_white.svg",
timeout=5.0,
"https://docs.paperless-ngx.com/assets/logo_full_white.svg",
)
resp.raise_for_status()
assert resp.status_code == httpx.codes.OK
assert "svg" in resp.headers.get("content-type", "").lower()
def test_server_connectivity(
self,
nginx_base_url: str,
) -> None:
"""
GIVEN:
- Local test fixtures server should be running
WHEN:
- A request is made to the server root
THEN:
- The server shall respond without connection errors
"""
try:
resp = httpx.get(
nginx_base_url,
timeout=5.0,
follow_redirects=True,
)
# We don't care about the status code, just that we can connect
assert resp.status_code in {200, 404, 403}
except httpx.ConnectError as e:
pytest.fail(
f"Cannot connect to nginx server at {nginx_base_url}. "
f"Ensure the nginx container is running via docker-compose.ci-test.yml. "
f"Error: {e}",
)
@pytest.mark.live
@pytest.mark.gotenberg
@pytest.mark.tika
@pytest.mark.nginx
@pytest.mark.skipif(
"PAPERLESS_CI_TEST" not in os.environ,
reason="No Gotenberg/Tika servers to test with",
)
class TestParserLive:
@staticmethod
def imagehash(file: Path, hash_size: int = 18) -> str:
def imagehash(file, hash_size=18):
return f"{average_hash(Image.open(file), hash_size)}"
def test_get_thumbnail(
@@ -148,15 +112,14 @@ class TestParserLive:
simple_txt_email_file: Path,
simple_txt_email_pdf_file: Path,
simple_txt_email_thumbnail_file: Path,
) -> None:
):
"""
GIVEN:
- A simple text email file
- Mocked PDF generation returning a known PDF
- Fresh start
WHEN:
- The thumbnail is requested
- The Thumbnail is requested
THEN:
- The returned thumbnail image file shall match the expected hash
- The returned thumbnail image file is as expected
"""
mock_generate_pdf = mocker.patch(
"paperless_mail.parsers.MailDocumentParser.generate_pdf",
@@ -171,28 +134,22 @@ class TestParserLive:
assert self.imagehash(thumb) == self.imagehash(
simple_txt_email_thumbnail_file,
), (
f"Created thumbnail {thumb} differs from expected file "
f"{simple_txt_email_thumbnail_file}"
f"Created Thumbnail {thumb} differs from expected file {simple_txt_email_thumbnail_file}"
)
def test_tika_parse_successful(self, mail_parser: MailDocumentParser) -> None:
def test_tika_parse_successful(self, mail_parser: MailDocumentParser):
"""
GIVEN:
- HTML content to parse
- Tika server is running
- Fresh start
WHEN:
- Tika parsing is called
- tika parsing is called
THEN:
- A web request to Tika shall be made
- The parsed text content shall be returned
- a web request to tika shall be done and the reply es returned
"""
html = (
'<html><head><meta http-equiv="content-type" '
'content="text/html; charset=UTF-8"></head>'
"<body><p>Some Text</p></body></html>"
)
html = '<html><head><meta http-equiv="content-type" content="text/html; charset=UTF-8"></head><body><p>Some Text</p></body></html>'
expected_text = "Some Text"
# Check successful parsing
parsed = mail_parser.tika_parse(html)
assert expected_text == parsed.strip()
@@ -203,17 +160,14 @@ class TestParserLive:
html_email_file: Path,
merged_pdf_first: Path,
merged_pdf_second: Path,
) -> None:
):
"""
GIVEN:
- Intermediary PDFs to be merged
- An HTML email file
- Intermediary pdfs to be merged
WHEN:
- PDF generation is requested with HTML file requiring merging
- pdf generation is requested with html file requiring merging of pdfs
THEN:
- Gotenberg shall be called to merge files
- The resulting merged PDF shall be returned
- The merged PDF shall contain text from both source PDFs
- gotenberg is called to merge files and the resulting file is returned
"""
mock_generate_pdf_from_html = mocker.patch(
"paperless_mail.parsers.MailDocumentParser.generate_pdf_from_html",
@@ -246,17 +200,16 @@ class TestParserLive:
html_email_file: Path,
html_email_pdf_file: Path,
html_email_thumbnail_file: Path,
) -> None:
):
"""
GIVEN:
- An HTML email file
- Fresh start
WHEN:
- PDF generation from the email file is requested
- pdf generation from simple eml file is requested
THEN:
- Gotenberg shall be called to generate the PDF
- The archive PDF shall contain the expected content
- The generated thumbnail shall match the expected image hash
- Gotenberg is called and the resulting file is returned and look as expected.
"""
util_call_with_backoff(mail_parser.parse, [html_email_file, "message/rfc822"])
# Check the archive PDF
@@ -264,7 +217,7 @@ class TestParserLive:
archive_text = extract_text(archive_path)
expected_archive_text = extract_text(html_email_pdf_file)
# Archive includes the HTML content
# Archive includes the HTML content, so use in
assert expected_archive_text in archive_text
# Check the thumbnail
@@ -274,12 +227,9 @@ class TestParserLive:
)
generated_thumbnail_hash = self.imagehash(generated_thumbnail)
# The created PDF is not reproducible, but the converted image
# should always look the same
# The created pdf is not reproducible. But the converted image should always look the same.
expected_hash = self.imagehash(html_email_thumbnail_file)
assert generated_thumbnail_hash == expected_hash, (
f"PDF thumbnail differs from expected. "
f"Generated: {generated_thumbnail}, "
f"Hash: {generated_thumbnail_hash} vs {expected_hash}"
f"PDF looks different. Check if {generated_thumbnail} looks weird."
)

View File

@@ -1,12 +1,7 @@
import datetime
import logging
from datetime import timedelta
from typing import Any
from adrf.views import APIView
from adrf.viewsets import ModelViewSet
from adrf.viewsets import ReadOnlyModelViewSet
from asgiref.sync import sync_to_async
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
@@ -20,9 +15,11 @@ from httpx_oauth.oauth2 import GetAccessTokenError
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ReadOnlyModelViewSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.permissions import PaperlessObjectPermissions
@@ -42,8 +39,6 @@ from paperless_mail.serialisers import MailRuleSerializer
from paperless_mail.serialisers import ProcessedMailSerializer
from paperless_mail.tasks import process_mail_accounts
logger: logging.Logger = logging.getLogger("paperless_mail")
@extend_schema_view(
test=extend_schema(
@@ -71,75 +66,71 @@ logger: logging.Logger = logging.getLogger("paperless_mail")
),
)
class MailAccountViewSet(ModelViewSet, PassUserMixin):
model = MailAccount
queryset = MailAccount.objects.all().order_by("pk")
serializer_class = MailAccountSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
def get_permissions(self) -> list[Any]:
def get_permissions(self):
if self.action == "test":
return [IsAuthenticated()]
# Test action does not require object level permissions
self.permission_classes = (IsAuthenticated,)
return super().get_permissions()
@action(methods=["post"], detail=False)
async def test(self, request: Request) -> Response | HttpResponseBadRequest:
def test(self, request):
logger = logging.getLogger("paperless_mail")
request.data["name"] = datetime.datetime.now().isoformat()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Validation must be wrapped because of sync DB validators
await sync_to_async(serializer.is_valid)(raise_exception=True)
validated_data: dict[str, Any] = serializer.validated_data
# account exists, use the password from there instead of *** and refresh_token / expiration
if (
len(str(validated_data.get("password", "")).replace("*", "")) == 0
and request.data.get("id") is not None
len(serializer.validated_data.get("password").replace("*", "")) == 0
and request.data["id"] is not None
):
existing_account = await MailAccount.objects.aget(pk=request.data["id"])
validated_data.update(
{
"password": existing_account.password,
"account_type": existing_account.account_type,
"refresh_token": existing_account.refresh_token,
"expiration": existing_account.expiration,
},
)
existing_account = MailAccount.objects.get(pk=request.data["id"])
serializer.validated_data["password"] = existing_account.password
serializer.validated_data["account_type"] = existing_account.account_type
serializer.validated_data["refresh_token"] = existing_account.refresh_token
serializer.validated_data["expiration"] = existing_account.expiration
account = MailAccount(**validated_data)
def _blocking_imap_test() -> bool:
with get_mailbox(
account.imap_server,
account.imap_port,
account.imap_security,
) as m_box:
account = MailAccount(**serializer.validated_data)
with get_mailbox(
account.imap_server,
account.imap_port,
account.imap_security,
) as M:
try:
if (
account.is_token
and account.expiration
and account.expiration is not None
and account.expiration < timezone.now()
):
oauth_manager = PaperlessMailOAuth2Manager()
if oauth_manager.refresh_account_oauth_token(existing_account):
# User is not changing password and token needs to be refreshed
existing_account.refresh_from_db()
account.password = existing_account.password
else:
raise MailError("Unable to refresh oauth token")
mailbox_login(m_box, account)
return True
try:
await sync_to_async(_blocking_imap_test, thread_sensitive=False)()
return Response({"success": True})
except MailError as e:
logger.error(f"Mail account {account} test failed: {e}")
return HttpResponseBadRequest("Unable to connect to server")
mailbox_login(M, account)
return Response({"success": True})
except MailError as e:
logger.error(
f"Mail account {account} test failed: {e}",
)
return HttpResponseBadRequest("Unable to connect to server")
@action(methods=["post"], detail=True)
async def process(self, request: Request, pk: int | None = None) -> Response:
# FIX: Use aget_object() provided by adrf to avoid SynchronousOnlyOperation
account = await self.aget_object()
def process(self, request, pk=None):
account = self.get_object()
process_mail_accounts.delay([account.pk])
return Response({"result": "OK"})
@@ -153,38 +144,21 @@ class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = ProcessedMailFilterSet
queryset = ProcessedMail.objects.all().order_by("-processed")
@action(methods=["post"], detail=False)
async def bulk_delete(
self,
request: Request,
) -> Response | HttpResponseBadRequest | HttpResponseForbidden:
mail_ids: list[int] = request.data.get("mail_ids", [])
def bulk_delete(self, request):
mail_ids = request.data.get("mail_ids", [])
if not isinstance(mail_ids, list) or not all(
isinstance(i, int) for i in mail_ids
):
return HttpResponseBadRequest("mail_ids must be a list of integers")
# Store objects to delete after verification
to_delete: list[ProcessedMail] = []
# We must verify permissions for every requested ID
async for mail in ProcessedMail.objects.filter(id__in=mail_ids):
can_delete = await sync_to_async(has_perms_owner_aware)(
request.user,
"delete_processedmail",
mail,
)
if not can_delete:
# This is what the test is looking for: 403 on permission failure
mails = ProcessedMail.objects.filter(id__in=mail_ids)
for mail in mails:
if not has_perms_owner_aware(request.user, "delete_processedmail", mail):
return HttpResponseForbidden("Insufficient permissions")
to_delete.append(mail)
# Only perform deletions if all items passed the permission check
for mail in to_delete:
await mail.adelete()
mail.delete()
return Response({"result": "OK", "deleted_mail_ids": mail_ids})
@@ -204,74 +178,77 @@ class MailRuleViewSet(ModelViewSet, PassUserMixin):
responses={200: None},
),
)
class OauthCallbackView(APIView):
class OauthCallbackView(GenericAPIView):
permission_classes = (IsAuthenticated,)
async def get(
self,
request: Request,
) -> Response | HttpResponseBadRequest | HttpResponseRedirect:
has_perm = await sync_to_async(request.user.has_perm)(
"paperless_mail.add_mailaccount",
)
if not has_perm:
def get(self, request, format=None):
if not (
request.user and request.user.has_perms(["paperless_mail.add_mailaccount"])
):
return HttpResponseBadRequest(
"You do not have permission to add mail accounts",
)
code: str | None = request.query_params.get("code")
state: str | None = request.query_params.get("state")
scope: str | None = request.query_params.get("scope")
logger = logging.getLogger("paperless_mail")
code = request.query_params.get("code")
# Gmail passes scope as a query param, Outlook does not
scope = request.query_params.get("scope")
if not code or not state:
return HttpResponseBadRequest("Invalid request parameters")
if code is None:
logger.error(
f"Invalid oauth callback request, code: {code}, scope: {scope}",
)
return HttpResponseBadRequest("Invalid request, see logs for more detail")
oauth_manager = PaperlessMailOAuth2Manager(
state=request.session.get("oauth_state"),
)
state = request.query_params.get("state", "")
if not oauth_manager.validate_state(state):
return HttpResponseBadRequest("Invalid OAuth state")
logger.error(
f"Invalid oauth callback request received state: {state}, expected: {oauth_manager.state}",
)
return HttpResponseBadRequest("Invalid request, see logs for more detail")
try:
defaults: dict[str, Any] = {
"username": "",
"imap_security": MailAccount.ImapSecurity.SSL,
"imap_port": 993,
}
if scope and "google" in scope:
if scope is not None and "google" in scope:
# Google
account_type = MailAccount.MailAccountType.GMAIL_OAUTH
imap_server = "imap.gmail.com"
defaults.update(
{
"name": f"Gmail OAuth {timezone.now()}",
"account_type": account_type,
},
)
result = await sync_to_async(oauth_manager.get_gmail_access_token)(code)
else:
defaults = {
"name": f"Gmail OAuth {timezone.now()}",
"username": "",
"imap_security": MailAccount.ImapSecurity.SSL,
"imap_port": 993,
"account_type": account_type,
}
result = oauth_manager.get_gmail_access_token(code)
elif scope is None:
# Outlook
account_type = MailAccount.MailAccountType.OUTLOOK_OAUTH
imap_server = "outlook.office365.com"
defaults.update(
{
"name": f"Outlook OAuth {timezone.now()}",
"account_type": account_type,
},
)
result = await sync_to_async(oauth_manager.get_outlook_access_token)(
code,
)
defaults = {
"name": f"Outlook OAuth {timezone.now()}",
"username": "",
"imap_security": MailAccount.ImapSecurity.SSL,
"imap_port": 993,
"account_type": account_type,
}
account, _ = await MailAccount.objects.aupdate_or_create(
result = oauth_manager.get_outlook_access_token(code)
access_token = result["access_token"]
refresh_token = result["refresh_token"]
expires_in = result["expires_in"]
account, _ = MailAccount.objects.update_or_create(
password=access_token,
is_token=True,
imap_server=imap_server,
refresh_token=result["refresh_token"],
defaults={
**defaults,
"password": result["access_token"],
"is_token": True,
"expiration": timezone.now()
+ timedelta(seconds=result["expires_in"]),
},
refresh_token=refresh_token,
expiration=timezone.now() + timedelta(seconds=expires_in),
defaults=defaults,
)
return HttpResponseRedirect(
f"{oauth_manager.oauth_redirect_url}?oauth_success=1&account_id={account.pk}",

25
uv.lock generated
View File

@@ -16,20 +16,6 @@ supported-markers = [
"sys_platform == 'linux'",
]
[[package]]
name = "adrf"
version = "0.1.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-property", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "djangorestframework", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/fe/573d7ec8805aec8e13459451f81398c6ac819882496e82fb6b4ae96c2762/adrf-0.1.12.tar.gz", hash = "sha256:e7aa49e5406b168f040f1a12cafb606e98fdd5467314240a9c42dbe63200d2c1", size = 17181, upload-time = "2025-11-24T03:25:44.337Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/47/14006c4045f818cf625d2357e823a15b4bb7fab8dd81a40acd23af41ee53/adrf-0.1.12-py3-none-any.whl", hash = "sha256:e9d1f343b82158f4c528c0809c9635a27ceef4c37d3d8e61b8096c8eeded616d", size = 20199, upload-time = "2025-11-24T03:25:43.291Z" },
]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
@@ -217,15 +203,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" },
]
[[package]]
name = "async-property"
version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/12/900eb34b3af75c11b69d6b78b74ec0fd1ba489376eceb3785f787d1a0a1d/async_property-0.2.2.tar.gz", hash = "sha256:17d9bd6ca67e27915a75d92549df64b5c7174e9dc806b30a3934dc4ff0506380", size = 16523, upload-time = "2023-07-03T17:21:55.688Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/80/9f608d13b4b3afcebd1dd13baf9551c95fc424d6390e4b1cfd7b1810cd06/async_property-0.2.2-py2.py3-none-any.whl", hash = "sha256:8924d792b5843994537f8ed411165700b27b2bd966cefc4daeefc1253442a9d7", size = 9546, upload-time = "2023-07-03T17:21:54.293Z" },
]
[[package]]
name = "async-timeout"
version = "5.0.1"
@@ -2942,7 +2919,6 @@ name = "paperless-ngx"
version = "2.20.5"
source = { virtual = "." }
dependencies = [
{ name = "adrf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "bleach", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
@@ -3091,7 +3067,6 @@ typing = [
[package.metadata]
requires-dist = [
{ name = "adrf", specifier = "~=0.1.12" },
{ name = "azure-ai-documentintelligence", specifier = ">=1.0.2" },
{ name = "babel", specifier = ">=2.17" },
{ name = "bleach", specifier = "~=6.3.0" },