Compare commits

..

15 Commits

Author SHA1 Message Date
shamoon
b436530e4f Fix tests 2025-09-20 16:08:36 -07:00
shamoon
0ab94ab130 Make head_version and versions read-only via API 2025-09-20 15:38:21 -07:00
shamoon
ce5f5140f9 Random cleanup 2025-09-20 10:47:56 -07:00
shamoon
d8cb07b4a6 Llint 2025-09-20 10:17:54 -07:00
shamoon
1e48f9f9a9 Fix migration 2025-09-20 10:11:03 -07:00
shamoon
dc20db39e7 Fix caching
[ci skip]
2025-09-20 10:10:09 -07:00
shamoon
065f501272 Fix frontend versions switching
[ci skip]
2025-09-20 10:10:09 -07:00
shamoon
339a4db893 Update views.py 2025-09-20 10:10:08 -07:00
shamoon
0cc5f12cbf version aware doc endpoints 2025-09-20 10:10:08 -07:00
shamoon
e099998b2f Fix archive filename clash 2025-09-20 10:10:07 -07:00
shamoon
521628c1c3 Super basic UI stuff
[ci skip]
2025-09-20 10:10:07 -07:00
shamoon
80ed84f538 Bulk editing to update version instead of replace 2025-09-20 10:10:06 -07:00
shamoon
2557c03463 Fix migration 2025-09-20 10:09:35 -07:00
shamoon
9ed75561e7 Basic start of update endpoint 2025-09-20 10:09:34 -07:00
shamoon
02a7500696 Add head_version 2025-09-20 10:09:31 -07:00
46 changed files with 619 additions and 1262 deletions

View File

@@ -151,18 +151,6 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: coverage.xml files: coverage.xml
- name: Upload coverage artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: backend-coverage-${{ matrix.python-version }}
path: |
.coverage
coverage.xml
junit.xml
retention-days: 1
include-hidden-files: true
if-no-files-found: error
- name: Stop containers - name: Stop containers
if: always() if: always()
run: | run: |
@@ -245,17 +233,6 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/ directory: src-ui/coverage/
- name: Upload coverage artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: frontend-coverage-${{ matrix.shard-index }}
path: |
src-ui/coverage/lcov.info
src-ui/coverage/coverage-final.json
src-ui/junit.xml
retention-days: 1
if-no-files-found: error
tests-frontend-e2e: tests-frontend-e2e:
name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -336,74 +313,6 @@ jobs:
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: cd src-ui && pnpm run build --configuration=production run: cd src-ui && pnpm run build --configuration=production
sonarqube-analysis:
name: "SonarQube Analysis"
runs-on: ubuntu-24.04
needs:
- tests-backend
- tests-frontend
if: github.repository_owner == 'paperless-ngx'
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Download all backend coverage
uses: actions/download-artifact@v5.0.0
with:
pattern: backend-coverage-*
path: ./coverage/
- name: Download all frontend coverage
uses: actions/download-artifact@v5.0.0
with:
pattern: frontend-coverage-*
path: ./coverage/
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install coverage tools
run: |
pip install coverage
npm install -g nyc
# Merge backend coverage from all Python versions
- name: Merge backend coverage
run: |
coverage combine coverage/backend-coverage-*/.coverage
coverage xml -o merged-backend-coverage.xml
# Merge frontend coverage from all shards
- name: Merge frontend coverage
run: |
# Find all coverage-final.json files from the shards, exit with error if none found
shopt -s nullglob
files=(coverage/frontend-coverage-*/coverage/coverage-final.json)
if [ ${#files[@]} -eq 0 ]; then
echo "No frontend coverage JSON found under coverage/" >&2
exit 1
fi
# Create .nyc_output directory and copy each shard's coverage JSON into it with a unique name
mkdir -p .nyc_output
for coverage_json in "${files[@]}"; do
shard=$(basename "$(dirname "$(dirname "$coverage_json")")")
cp "$coverage_json" ".nyc_output/${shard}.json"
done
npx nyc merge .nyc_output .nyc_output/out.json
npx nyc report --reporter=lcovonly --report-dir coverage
- name: Upload coverage artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: merged-coverage
path: |
merged-backend-coverage.xml
.nyc_output/*
coverage/lcov.info
retention-days: 7
if-no-files-found: error
include-hidden-files: true
- name: SonarQube Analysis
uses: SonarSource/sonarqube-scan-action@v5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
build-docker-image: build-docker-image:
name: Build Docker image for ${{ github.ref_name }} name: Build Docker image for ${{ github.ref_name }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04

View File

@@ -241,7 +241,6 @@ jobs:
) { ) {
nodes { nodes {
id, id,
createdAt,
number, number,
updatedAt, updatedAt,
upvoteCount, upvoteCount,

View File

@@ -2,11 +2,9 @@
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome. If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
If you want to implement something big: If you want to implement something big:
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together. - Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together.
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
- Please see the [paperless-ngx merge process](#merging-prs) below. - Please see the [paperless-ngx merge process](#merging-prs) below.
@@ -135,7 +133,7 @@ community members. That said, in an effort to keep the repository organized and
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
- Discussions with a marked answer will be automatically closed. - Discussions with a marked answer will be automatically closed.
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. - Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years. - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.

View File

@@ -32,7 +32,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.8.19-python3.12-bookworm-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.8.17-python3.12-bookworm-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6

View File

@@ -1759,11 +1759,6 @@ started by the container.
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg` : Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
!!! note
The logo file will be viewable by anyone with access to the Paperless instance login page,
so consider your choice of logo carefully and removing exif data from images before uploading.
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK} #### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
!!! note !!! note

View File

@@ -261,10 +261,6 @@ different means. These are as follows:
Paperless is set up to check your mails every 10 minutes. This can be Paperless is set up to check your mails every 10 minutes. This can be
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON) configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
#### Processed Mail
Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs.
#### OAuth Email Setup #### OAuth Email Setup
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly. Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.

View File

@@ -255,7 +255,6 @@ PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
[tool.coverage.run] [tool.coverage.run]
relative_files = true
source = [ source = [
"src/", "src/",
] ]

View File

@@ -1,24 +0,0 @@
sonar.projectKey=paperless-ngx_paperless-ngx
sonar.organization=paperless-ngx
sonar.projectName=Paperless-ngx
sonar.projectVersion=1.0
# Source and test directories
sonar.sources=src/,src-ui/
sonar.test.inclusions=**/test_*.py,**/tests.py,**/*.spec.ts,**/*.test.ts
# Language specific settings
sonar.python.version=3.10,3.11,3.12,3.13
# Coverage reports
sonar.python.coverage.reportPaths=merged-backend-coverage.xml
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# Test execution reports
sonar.junit.reportPaths=**/junit.xml,**/test-results.xml
# Encoding
sonar.sourceEncoding=UTF-8
# Exclusions
sonar.exclusions=**/migrations/**,**/node_modules/**,**/static/**,**/venv/**,**/.venv/**,**/dist/**

View File

@@ -755,15 +755,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">123</context> <context context-type="linenumber">122</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">192</context> <context context-type="linenumber">186</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">16</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
@@ -976,10 +972,6 @@
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context> <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">4</context> <context context-type="linenumber">4</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">3</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="6226301160429720843" datatype="html"> <trans-unit id="6226301160429720843" datatype="html">
<source> Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. </source> <source> Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. </source>
@@ -1225,11 +1217,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">148</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">166</context> <context context-type="linenumber">160</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
@@ -1820,7 +1812,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">116</context> <context context-type="linenumber">115</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
@@ -2012,14 +2004,6 @@
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
<context context-type="linenumber">14</context> <context context-type="linenumber">14</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">87</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">89</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="8597030111956627342" datatype="html"> <trans-unit id="8597030111956627342" datatype="html">
<source>Empty trash</source> <source>Empty trash</source>
@@ -2129,11 +2113,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">155</context> <context context-type="linenumber">149</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">169</context> <context context-type="linenumber">163</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
@@ -2257,11 +2241,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">192</context> <context context-type="linenumber">191</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">293</context> <context context-type="linenumber">292</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
@@ -2448,11 +2432,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">153</context> <context context-type="linenumber">147</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">163</context> <context context-type="linenumber">157</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
@@ -2584,11 +2568,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">194</context> <context context-type="linenumber">193</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">295</context> <context context-type="linenumber">294</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
@@ -3145,10 +3129,6 @@
<context context-type="sourcefile">src/app/components/common/clearable-badge/clearable-badge.component.html</context> <context context-type="sourcefile">src/app/components/common/clearable-badge/clearable-badge.component.html</context>
<context context-type="linenumber">2</context> <context context-type="linenumber">2</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">85</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="7515883357904500238" datatype="html"> <trans-unit id="7515883357904500238" datatype="html">
<source>Are you sure?</source> <source>Are you sure?</source>
@@ -3916,7 +3896,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">137</context> <context context-type="linenumber">136</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
@@ -4126,10 +4106,6 @@
<context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context> <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
<context context-type="linenumber">30</context> <context context-type="linenumber">30</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="6886003843406464884" datatype="html"> <trans-unit id="6886003843406464884" datatype="html">
<source>Only process attachments</source> <source>Only process attachments</source>
@@ -5133,10 +5109,6 @@
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
<context context-type="linenumber">11</context> <context context-type="linenumber">11</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="8066608938393600549" datatype="html"> <trans-unit id="8066608938393600549" datatype="html">
<source>Message</source> <source>Message</source>
@@ -5506,10 +5478,6 @@
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context> <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
<context context-type="linenumber">9</context> <context context-type="linenumber">9</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">7</context>
</context-group>
</trans-unit> </trans-unit>
<trans-unit id="5034217198277582100" datatype="html"> <trans-unit id="5034217198277582100" datatype="html">
<source>Select all pages</source> <source>Select all pages</source>
@@ -5777,11 +5745,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">156</context> <context context-type="linenumber">150</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">174</context> <context context-type="linenumber">168</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
@@ -6159,10 +6127,6 @@
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">114</context> <context context-type="linenumber">114</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">35</context>
</context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
<context context-type="linenumber">19</context> <context context-type="linenumber">19</context>
@@ -8553,227 +8517,185 @@
<source>Disabled</source> <source>Disabled</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">137</context> <context context-type="linenumber">136</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
<context context-type="linenumber">41</context> <context context-type="linenumber">41</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8996068874121140407" datatype="html">
<source>View Processed Mail</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">143</context>
</context-group>
</trans-unit>
<trans-unit id="6751234988479444294" datatype="html"> <trans-unit id="6751234988479444294" datatype="html">
<source>No mail rules defined.</source> <source>No mail rules defined.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<context context-type="linenumber">183</context> <context context-type="linenumber">177</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3178554336792037159" datatype="html"> <trans-unit id="3178554336792037159" datatype="html">
<source>Error retrieving mail accounts</source> <source>Error retrieving mail accounts</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">105</context> <context context-type="linenumber">104</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5241231471117657636" datatype="html"> <trans-unit id="5241231471117657636" datatype="html">
<source>Error retrieving mail rules</source> <source>Error retrieving mail rules</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">127</context> <context context-type="linenumber">126</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="763945516325093575" datatype="html"> <trans-unit id="763945516325093575" datatype="html">
<source>OAuth2 authentication success</source> <source>OAuth2 authentication success</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">135</context> <context context-type="linenumber">134</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9022978370268070156" datatype="html"> <trans-unit id="9022978370268070156" datatype="html">
<source>OAuth2 authentication failed, see logs for details</source> <source>OAuth2 authentication failed, see logs for details</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">146</context> <context context-type="linenumber">145</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6327501535846658797" datatype="html"> <trans-unit id="6327501535846658797" datatype="html">
<source>Saved account &quot;<x id="PH" equiv-text="newMailAccount.name"/>&quot;.</source> <source>Saved account &quot;<x id="PH" equiv-text="newMailAccount.name"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">170</context> <context context-type="linenumber">169</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8067594003836508139" datatype="html"> <trans-unit id="8067594003836508139" datatype="html">
<source>Error saving account.</source> <source>Error saving account.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">182</context> <context context-type="linenumber">181</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5641934153807844674" datatype="html"> <trans-unit id="5641934153807844674" datatype="html">
<source>Confirm delete mail account</source> <source>Confirm delete mail account</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">190</context> <context context-type="linenumber">189</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7176985344323395435" datatype="html"> <trans-unit id="7176985344323395435" datatype="html">
<source>This operation will permanently delete this mail account.</source> <source>This operation will permanently delete this mail account.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">191</context> <context context-type="linenumber">190</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5876433590301754883" datatype="html"> <trans-unit id="5876433590301754883" datatype="html">
<source>Deleted mail account &quot;<x id="PH" equiv-text="account.name"/>&quot;</source> <source>Deleted mail account &quot;<x id="PH" equiv-text="account.name"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">201</context> <context context-type="linenumber">200</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5981429299543258715" datatype="html"> <trans-unit id="5981429299543258715" datatype="html">
<source>Error deleting mail account &quot;<x id="PH" equiv-text="account.name"/>&quot;.</source> <source>Error deleting mail account &quot;<x id="PH" equiv-text="account.name"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">212</context> <context context-type="linenumber">211</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6424800796582120505" datatype="html"> <trans-unit id="6424800796582120505" datatype="html">
<source>Processing mail account &quot;<x id="PH" equiv-text="account.name"/>&quot;</source> <source>Processing mail account &quot;<x id="PH" equiv-text="account.name"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">224</context> <context context-type="linenumber">223</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3138185874003827652" datatype="html"> <trans-unit id="3138185874003827652" datatype="html">
<source>Error processing mail account &quot;<x id="PH" equiv-text="account.name"/>&quot;</source> <source>Error processing mail account &quot;<x id="PH" equiv-text="account.name"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">229</context> <context context-type="linenumber">228</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="123368655395433699" datatype="html"> <trans-unit id="123368655395433699" datatype="html">
<source>Saved rule &quot;<x id="PH" equiv-text="newMailRule.name"/>&quot;.</source> <source>Saved rule &quot;<x id="PH" equiv-text="newMailRule.name"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">247</context> <context context-type="linenumber">246</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8951124554918814321" datatype="html"> <trans-unit id="8951124554918814321" datatype="html">
<source>Error saving rule.</source> <source>Error saving rule.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">258</context> <context context-type="linenumber">257</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3574401690710711341" datatype="html"> <trans-unit id="3574401690710711341" datatype="html">
<source>Rule &quot;<x id="PH" equiv-text="rule.name"/>&quot; enabled.</source> <source>Rule &quot;<x id="PH" equiv-text="rule.name"/>&quot; enabled.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">274</context> <context context-type="linenumber">273</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7171685227222299542" datatype="html"> <trans-unit id="7171685227222299542" datatype="html">
<source>Rule &quot;<x id="PH" equiv-text="rule.name"/>&quot; disabled.</source> <source>Rule &quot;<x id="PH" equiv-text="rule.name"/>&quot; disabled.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">275</context> <context context-type="linenumber">274</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7238791203524413596" datatype="html"> <trans-unit id="7238791203524413596" datatype="html">
<source>Error toggling rule &quot;<x id="PH" equiv-text="rule.name"/>&quot;.</source> <source>Error toggling rule &quot;<x id="PH" equiv-text="rule.name"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">280</context> <context context-type="linenumber">279</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3896080636020672118" datatype="html"> <trans-unit id="3896080636020672118" datatype="html">
<source>Confirm delete mail rule</source> <source>Confirm delete mail rule</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">291</context> <context context-type="linenumber">290</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2250372580580310337" datatype="html"> <trans-unit id="2250372580580310337" datatype="html">
<source>This operation will permanently delete this mail rule.</source> <source>This operation will permanently delete this mail rule.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">292</context> <context context-type="linenumber">291</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4357654589451732716" datatype="html"> <trans-unit id="4357654589451732716" datatype="html">
<source>Deleted mail rule &quot;<x id="PH" equiv-text="rule.name"/>&quot;</source> <source>Deleted mail rule &quot;<x id="PH" equiv-text="rule.name"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">302</context> <context context-type="linenumber">301</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1696130068388341598" datatype="html"> <trans-unit id="1696130068388341598" datatype="html">
<source>Error deleting mail rule &quot;<x id="PH" equiv-text="rule.name"/>&quot;.</source> <source>Error deleting mail rule &quot;<x id="PH" equiv-text="rule.name"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">313</context> <context context-type="linenumber">312</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3061362835271417984" datatype="html"> <trans-unit id="3061362835271417984" datatype="html">
<source>Permissions updated</source> <source>Permissions updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">337</context> <context context-type="linenumber">336</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4639647950943944112" datatype="html"> <trans-unit id="4639647950943944112" datatype="html">
<source>Error updating permissions</source> <source>Error updating permissions</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
<context context-type="linenumber">342</context> <context context-type="linenumber">341</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
<context context-type="linenumber">339</context> <context context-type="linenumber">339</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3501895737484542570" datatype="html">
<source>Processed Mail for <x id="START_EMPHASISED_TEXT" ctype="x-em" equiv-text="&lt;em&gt;"/><x id="INTERPOLATION" equiv-text="{{ rule.name }}"/><x id="CLOSE_EMPHASISED_TEXT" ctype="x-em" equiv-text="&lt;/em&gt;"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">2</context>
</context-group>
</trans-unit>
<trans-unit id="1991019495862291373" datatype="html">
<source>No processed email messages found.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="8691920320483720007" datatype="html">
<source>Received</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="4749295647449765550" datatype="html">
<source>Processed</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context>
<context context-type="linenumber">34</context>
</context-group>
</trans-unit>
<trans-unit id="2175109571923803648" datatype="html">
<source>Processed mail(s) deleted</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="4010735610815226758" datatype="html"> <trans-unit id="4010735610815226758" datatype="html">
<source>Filter by:</source> <source>Filter by:</source>
<context-group purpose="location"> <context-group purpose="location">

View File

@@ -1,7 +1,30 @@
<pngx-page-header [(title)]="title"> <pngx-page-header [(title)]="title">
@if (document?.versions?.length > 0) {
<div class="btn-group" ngbDropdown role="group">
<div class="btn-group" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle [disabled]="!hasVersions">
<i-bs name="layers"></i-bs>
<span class="d-none d-lg-inline ps-1" i18n>Version</span>
</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
@for (vid of document.versions; track vid) {
<button ngbDropdownItem (click)="selectVersion(vid)">
<span i18n>Version</span> {{vid}}
@if (selectedVersionId === vid) { <span>&nbsp;</span> }
</button>
}
</div>
</div>
<input #versionFileInput type="file" class="visually-hidden" (change)="onVersionFileSelected($event)" />
<button class="btn btn-sm btn-outline-secondary" title="Upload new version" i18n-title (click)="versionFileInput.click()" [disabled]="!userIsOwner || !userCanEdit">
<i-bs name="file-earmark-plus"></i-bs><span class="visually-hidden" i18n>Upload new version</span>
</button>
</div>
}
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) { @if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages) { @if (previewNumPages) {
<div class="input-group input-group-sm d-none d-md-flex"> <div class="input-group input-group-sm ms-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div> <div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" /> <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div> <div class="input-group-text" i18n>of {{previewNumPages}}</div>

View File

@@ -222,6 +222,8 @@ export class DocumentDetailComponent
titleSubject: Subject<string> = new Subject() titleSubject: Subject<string> = new Subject()
previewUrl: string previewUrl: string
thumbUrl: string thumbUrl: string
// Versioning: which document ID to use for file preview/download
selectedVersionId: number
previewText: string previewText: string
previewLoaded: boolean = false previewLoaded: boolean = false
tiffURL: string tiffURL: string
@@ -270,6 +272,7 @@ export class DocumentDetailComponent
public readonly DataType = DataType public readonly DataType = DataType
@ViewChild('nav') nav: NgbNav @ViewChild('nav') nav: NgbNav
@ViewChild('versionFileInput') versionFileInput
@ViewChild('pdfPreview') set pdfPreview(element) { @ViewChild('pdfPreview') set pdfPreview(element) {
// this gets called when component added or removed from DOM // this gets called when component added or removed from DOM
if ( if (
@@ -402,7 +405,10 @@ export class DocumentDetailComponent
} }
private loadDocument(documentId: number): void { private loadDocument(documentId: number): void {
this.previewUrl = this.documentsService.getPreviewUrl(documentId) this.selectedVersionId = documentId
this.previewUrl = this.documentsService.getPreviewUrl(
this.selectedVersionId
)
this.http this.http
.get(this.previewUrl, { responseType: 'text' }) .get(this.previewUrl, { responseType: 'text' })
.pipe( .pipe(
@@ -417,7 +423,7 @@ export class DocumentDetailComponent
err.message ?? err.toString() err.message ?? err.toString()
}`), }`),
}) })
this.thumbUrl = this.documentsService.getThumbUrl(documentId) this.thumbUrl = this.documentsService.getThumbUrl(this.selectedVersionId)
this.documentsService this.documentsService
.get(documentId) .get(documentId)
.pipe( .pipe(
@@ -638,6 +644,10 @@ export class DocumentDetailComponent
updateComponent(doc: Document) { updateComponent(doc: Document) {
this.document = doc this.document = doc
// Default selected version is the newest version
this.selectedVersionId = doc.versions?.length
? Math.max(...doc.versions)
: doc.id
this.requiresPassword = false this.requiresPassword = false
this.updateFormForCustomFields() this.updateFormForCustomFields()
if (this.archiveContentRenderType === ContentRenderType.TIFF) { if (this.archiveContentRenderType === ContentRenderType.TIFF) {
@@ -702,6 +712,36 @@ export class DocumentDetailComponent
this.prepareForm(doc) this.prepareForm(doc)
} }
get hasVersions(): boolean {
return this.document?.versions?.length > 1
}
// Update file preview and download target to a specific version (by document id)
selectVersion(versionId: number) {
this.selectedVersionId = versionId
this.previewUrl = this.documentsService.getPreviewUrl(
this.documentId,
false,
this.selectedVersionId
)
this.thumbUrl = this.documentsService.getThumbUrl(this.selectedVersionId)
// For text previews, refresh content
this.http
.get(this.previewUrl, { responseType: 'text' })
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (res) => (this.previewText = res.toString()),
error: (err) =>
(this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`),
})
}
get customFieldFormFields(): FormArray { get customFieldFormFields(): FormArray {
return this.documentForm.get('custom_fields') as FormArray return this.documentForm.get('custom_fields') as FormArray
} }
@@ -1049,10 +1089,36 @@ export class DocumentDetailComponent
}) })
} }
onVersionFileSelected(event: Event) {
const input = event.target as HTMLInputElement
if (!input?.files || input.files.length === 0) return
const file = input.files[0]
// Reset input to allow re-selection of the same file later
input.value = ''
this.documentsService
.uploadVersion(this.documentId, file)
.pipe(first())
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Uploading new version. Processing will happen in the background.`
)
// Refresh metadata to reflect that versions changed (when ready)
this.openDocumentService.refreshDocument(this.documentId)
},
error: (error) => {
this.toastService.showError(
$localize`Error uploading new version`,
error
)
},
})
}
download(original: boolean = false) { download(original: boolean = false) {
this.downloading = true this.downloading = true
const downloadUrl = this.documentsService.getDownloadUrl( const downloadUrl = this.documentsService.getDownloadUrl(
this.documentId, this.selectedVersionId || this.documentId,
original original
) )
this.http this.http

View File

@@ -109,11 +109,10 @@
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row">
<div class="col" i18n>Name</div> <div class="col" i18n>Name</div>
<div class="col-1 d-none d-sm-block" i18n>Sort Order</div> <div class="col d-none d-sm-block" i18n>Sort Order</div>
<div class="col-2" i18n>Account</div> <div class="col" i18n>Account</div>
<div class="col-2 d-none d-sm-block" i18n>Status</div> <div class="col d-none d-sm-block" i18n>Status</div>
<div class="col d-none d-sm-block" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">Processed Mail</div> <div class="col" i18n>Actions</div>
<div class="col-3" i18n>Actions</div>
</div> </div>
</li> </li>
@@ -128,9 +127,9 @@
<li class="list-group-item"> <li class="list-group-item">
<div class="row fade" [class.show]="showRules"> <div class="row fade" [class.show]="showRules">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
<div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div> <div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
<div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
<div class="col-2 d-flex align-items-center d-none d-sm-flex"> <div class="col d-flex align-items-center d-none d-sm-flex">
<div class="form-check form-switch mb-0"> <div class="form-check form-switch mb-0">
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }"> <input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
<label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'"> <label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
@@ -138,12 +137,7 @@
</label> </label>
</div> </div>
</div> </div>
<div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }"> <div class="col">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)">
<i-bs width="1em" height="1em" name="clock-history"></i-bs>&nbsp;<ng-container i18n>View Processed Mail</ng-container>
</button>
</div>
<div class="col-3">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>

View File

@@ -409,13 +409,4 @@ describe('MailComponent', () => {
jest.advanceTimersByTime(200) jest.advanceTimersByTime(200)
expect(editSpy).toHaveBeenCalled() expect(editSpy).toHaveBeenCalled()
}) })
it('should open processed mails dialog', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.viewProcessedMail(mailRules[0] as MailRule)
const dialog = modal.componentInstance as any
expect(dialog.rule).toEqual(mailRules[0])
})
}) })

View File

@@ -27,7 +27,6 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component'
@Component({ @Component({
selector: 'pngx-mail', selector: 'pngx-mail',
@@ -348,14 +347,6 @@ export class MailComponent
) )
} }
viewProcessedMail(rule: MailRule) {
const modal = this.modalService.open(ProcessedMailDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.rule = rule
}
userCanEdit(obj: ObjectWithPermissions): boolean { userCanEdit(obj: ObjectWithPermissions): boolean {
return this.permissionsService.currentUserHasObjectPermissions( return this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change, PermissionAction.Change,

View File

@@ -1,107 +0,0 @@
<div class="modal-header">
<h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule.name }}</em></h6>
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<i-bs name="question-circle"></i-bs>
</button>
<ng-template #infoPopover>
<a href="https://docs.paperless-ngx.com/usage#processed-mail" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
</ng-template>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (loading) {
<div class="text-center my-5">
<div class="spinner-border" role="status">
<span class="visually-hidden" i18n>Loading...</span>
</div>
</div>
} @else if (processedMails.length === 0) {
<span i18n>No processed email messages found.</span>
} @else {
<div class="table-responsive">
<table class="table table-hover table-sm align-middle">
<thead>
<tr>
<th scope="col" style="width: 40px;">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
<th scope="col" i18n>Subject</th>
<th scope="col" i18n>Received</th>
<th scope="col" i18n>Processed</th>
<th scope="col" i18n>Status</th>
<th scope="col" i18n>Error</th>
</tr>
</thead>
<tbody>
@for (mail of processedMails; track mail.id) {
<ng-template #statusTooltip>
<div class="small text-light font-monospace">
{{mail.status}}
</div>
</ng-template>
<tr>
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();">
<label class="form-check-label" [for]="mail.id"></label>
</div>
</td>
<td>{{ mail.subject }}</td>
<td>{{ mail.received | customDate:'longDate' }}</td>
<td>{{ mail.processed | customDate:'longDate' }}</td>
<td>
@switch (mail.status) {
@case ('SUCCESS') {
<i-bs name="check-circle" title="SUCCESS" class="text-success" [ngbTooltip]="statusTooltip"></i-bs>
}
@case ('FAILED') {
<i-bs name="exclamation-triangle" title="FAILED" class="text-danger" [ngbTooltip]="statusTooltip"></i-bs>
}
@default {
<i-bs name="slash-circle" title="{{ mail.status }}" class="text-muted" [ngbTooltip]="statusTooltip"></i-bs>
}
}
</td>
<td>
<ng-template #errorPopover>
<pre class="small text-light">
{{ mail.error }}
</pre>
</ng-template>
@if (mail.error) {
<span class="text-danger" triggers="mouseenter:mouseleave" [ngbPopover]="errorPopover">{{ mail.error | slice:0:20 }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="btn-toolbar">
<button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button>
<pngx-confirm-button
label="Delete selected"
i18n-label
title="Delete selected"
i18n-title
buttonClasses="btn-outline-danger"
iconName="trash"
[disabled]="selectedMailIds.size === 0"
(confirm)="deleteSelected()">
</pngx-confirm-button>
<div class="ms-auto">
<ngb-pagination
[collectionSize]="processedMails.length"
[(page)]="page"
[pageSize]="50"
[maxSize]="5"
(pageChange)="loadProcessedMails()">
</ngb-pagination>
</div>
</div>
}
</div>

View File

@@ -1,8 +0,0 @@
::ng-deep .popover {
max-width: 350px;
pre {
white-space: pre-wrap;
word-break: break-word;
}
}

View File

@@ -1,150 +0,0 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ProcessedMailDialogComponent } from './processed-mail-dialog.component'
describe('ProcessedMailDialogComponent', () => {
let component: ProcessedMailDialogComponent
let fixture: ComponentFixture<ProcessedMailDialogComponent>
let httpTestingController: HttpTestingController
let toastService: ToastService
const rule: any = { id: 10, name: 'Mail Rule' } // minimal rule object for tests
const mails = [
{
id: 1,
rule: rule.id,
folder: 'INBOX',
uid: 111,
subject: 'A',
received: new Date().toISOString(),
processed: new Date().toISOString(),
status: 'SUCCESS',
error: null,
},
{
id: 2,
rule: rule.id,
folder: 'INBOX',
uid: 222,
subject: 'B',
received: new Date().toISOString(),
processed: new Date().toISOString(),
status: 'FAILED',
error: 'Oops',
},
]
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ProcessedMailDialogComponent,
FormsModule,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
DatePipe,
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
httpTestingController = TestBed.inject(HttpTestingController)
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(ProcessedMailDialogComponent)
component = fixture.componentInstance
component.rule = rule
})
afterEach(() => {
httpTestingController.verify()
})
function expectListRequest(ruleId: number) {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}processed_mail/?page=1&page_size=50&ordering=-processed_at&rule=${ruleId}`
)
expect(req.request.method).toEqual('GET')
return req
}
it('should load processed mails on init', () => {
fixture.detectChanges()
const req = expectListRequest(rule.id)
req.flush({ count: 2, results: mails })
expect(component.loading).toBeFalsy()
expect(component.processedMails).toEqual(mails)
})
it('should delete selected mails and reload', () => {
fixture.detectChanges()
// initial load
const initialReq = expectListRequest(rule.id)
initialReq.flush({ count: 0, results: [] })
// select a couple of mails and delete
component.selectedMailIds.add(5)
component.selectedMailIds.add(6)
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
component.deleteSelected()
const delReq = httpTestingController.expectOne(
`${environment.apiBaseUrl}processed_mail/bulk_delete/`
)
expect(delReq.request.method).toEqual('POST')
expect(delReq.request.body).toEqual({ mail_ids: [5, 6] })
delReq.flush({})
// reload after delete
const reloadReq = expectListRequest(rule.id)
reloadReq.flush({ count: 0, results: [] })
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should toggle all, toggle selected, and clear selection', () => {
fixture.detectChanges()
// initial load with two mails
const req = expectListRequest(rule.id)
req.flush({ count: 2, results: mails })
fixture.detectChanges()
// toggle all via header checkbox
const inputs = fixture.debugElement.queryAll(
By.css('input.form-check-input')
)
const header = inputs[0].nativeElement as HTMLInputElement
header.dispatchEvent(new Event('click'))
header.checked = true
header.dispatchEvent(new Event('click'))
expect(component.selectedMailIds.size).toEqual(mails.length)
// toggle a single mail
component.toggleSelected(mails[0] as any)
expect(component.selectedMailIds.has(mails[0].id)).toBeFalsy()
component.toggleSelected(mails[0] as any)
expect(component.selectedMailIds.has(mails[0].id)).toBeTruthy()
// clear selection
component.clearSelection()
expect(component.selectedMailIds.size).toEqual(0)
expect(component.toggleAllEnabled).toBeFalsy()
})
it('should close the dialog', () => {
const activeModal = TestBed.inject(NgbActiveModal)
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@@ -1,96 +0,0 @@
import { SlicePipe } from '@angular/common'
import { Component, inject, Input, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbActiveModal,
NgbPagination,
NgbPopoverModule,
NgbTooltipModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
import { MailRule } from 'src/app/data/mail-rule'
import { ProcessedMail } from 'src/app/data/processed-mail'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-processed-mail-dialog',
imports: [
ConfirmButtonComponent,
CustomDatePipe,
NgbPagination,
NgbPopoverModule,
NgbTooltipModule,
NgxBootstrapIconsModule,
FormsModule,
ReactiveFormsModule,
SlicePipe,
],
templateUrl: './processed-mail-dialog.component.html',
styleUrl: './processed-mail-dialog.component.scss',
})
export class ProcessedMailDialogComponent implements OnInit {
private readonly activeModal = inject(NgbActiveModal)
private readonly processedMailService = inject(ProcessedMailService)
private readonly toastService = inject(ToastService)
public processedMails: ProcessedMail[] = []
public loading: boolean = true
public toggleAllEnabled: boolean = false
public readonly selectedMailIds: Set<number> = new Set<number>()
public page: number = 1
@Input() rule: MailRule
ngOnInit(): void {
this.loadProcessedMails()
}
public close() {
this.activeModal.close()
}
private loadProcessedMails(): void {
this.loading = true
this.clearSelection()
this.processedMailService
.list(this.page, 50, 'processed_at', true, { rule: this.rule.id })
.subscribe((result) => {
this.processedMails = result.results
this.loading = false
})
}
public deleteSelected(): void {
this.processedMailService
.bulk_delete(Array.from(this.selectedMailIds))
.subscribe(() => {
this.toastService.showInfo($localize`Processed mail(s) deleted`)
this.loadProcessedMails()
})
}
public toggleAll(event: PointerEvent) {
if ((event.target as HTMLInputElement).checked) {
this.selectedMailIds.clear()
this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id))
} else {
this.clearSelection()
}
}
public clearSelection() {
this.toggleAllEnabled = false
this.selectedMailIds.clear()
}
public toggleSelected(mail: ProcessedMail) {
this.selectedMailIds.has(mail.id)
? this.selectedMailIds.delete(mail.id)
: this.selectedMailIds.add(mail.id)
}
}

View File

@@ -71,20 +71,4 @@ describe('TagListComponent', () => {
'Do you really want to delete the tag "Tag1"?' 'Do you really want to delete the tag "Tag1"?'
) )
}) })
it('should filter out child tags if name filter is empty, otherwise show all', () => {
const tags = [
{ id: 1, name: 'Tag1', parent: null },
{ id: 2, name: 'Tag2', parent: 1 },
{ id: 3, name: 'Tag3', parent: null },
]
component['_nameFilter'] = null // Simulate empty name filter
const filtered = component.filterData(tags as any)
expect(filtered.length).toBe(2)
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
const filteredWithName = component.filterData(tags as any)
expect(filteredWithName.length).toBe(3)
})
}) })

View File

@@ -62,8 +62,6 @@ export class TagListComponent extends ManagementListComponent<Tag> {
} }
filterData(data: Tag[]) { filterData(data: Tag[]) {
return this.nameFilter?.length return data.filter((tag) => !tag.parent)
? [...data]
: data.filter((tag) => !tag.parent)
} }
} }

View File

@@ -159,6 +159,10 @@ export interface Document extends ObjectWithPermissions {
page_count?: number page_count?: number
// Versioning
head_version?: number
versions?: number[]
// Frontend only // Frontend only
__changedFields?: string[] __changedFields?: string[]
} }

View File

@@ -1,12 +0,0 @@
import { ObjectWithId } from './object-with-id'
export interface ProcessedMail extends ObjectWithId {
rule: number // MailRule.id
folder: string
uid: number
subject: string
received: Date
processed: Date
status: string
error: string
}

View File

@@ -28,7 +28,6 @@ export enum PermissionType {
ShareLink = '%s_sharelink', ShareLink = '%s_sharelink',
CustomField = '%s_customfield', CustomField = '%s_customfield',
Workflow = '%s_workflow', Workflow = '%s_workflow',
ProcessedMail = '%s_processedmail',
} }
@Injectable({ @Injectable({

View File

@@ -163,12 +163,19 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}) })
} }
getPreviewUrl(id: number, original: boolean = false): string { getPreviewUrl(
id: number,
original: boolean = false,
versionID: number = null
): string {
let url = new URL(this.getResourceUrl(id, 'preview')) let url = new URL(this.getResourceUrl(id, 'preview'))
if (this._searchQuery) url.hash = `#search="${this.searchQuery}"` if (this._searchQuery) url.hash = `#search="${this.searchQuery}"`
if (original) { if (original) {
url.searchParams.append('original', 'true') url.searchParams.append('original', 'true')
} }
if (versionID) {
url.searchParams.append('version', versionID.toString())
}
return url.toString() return url.toString()
} }
@@ -184,6 +191,16 @@ export class DocumentService extends AbstractPaperlessService<Document> {
return url return url
} }
uploadVersion(documentId: number, file: File) {
const formData = new FormData()
formData.append('document', file, file.name)
return this.http.post(
this.getResourceUrl(documentId, 'update_version'),
formData,
{ reportProgress: true, observe: 'events' }
)
}
getNextAsn(): Observable<number> { getNextAsn(): Observable<number> {
return this.http.get<number>(this.getResourceUrl(null, 'next_asn')) return this.http.get<number>(this.getResourceUrl(null, 'next_asn'))
} }

View File

@@ -1,39 +0,0 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { ProcessedMailService } from './processed-mail.service'
let httpTestingController: HttpTestingController
let service: ProcessedMailService
let subscription: Subscription
const endpoint = 'processed_mail'
// run common tests
commonAbstractPaperlessServiceTests(endpoint, ProcessedMailService)
describe('Additional service tests for ProcessedMailService', () => {
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(ProcessedMailService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
})
it('should call appropriate api endpoint for bulk delete', () => {
const ids = [1, 2, 3]
subscription = service.bulk_delete(ids).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/bulk_delete/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ mail_ids: ids })
req.flush({})
})
})

View File

@@ -1,19 +0,0 @@
import { Injectable } from '@angular/core'
import { ProcessedMail } from 'src/app/data/processed-mail'
import { AbstractPaperlessService } from './abstract-paperless-service'
@Injectable({
providedIn: 'root',
})
export class ProcessedMailService extends AbstractPaperlessService<ProcessedMail> {
constructor() {
super()
this.resourceName = 'processed_mail'
}
public bulk_delete(mailIds: number[]) {
return this.http.post(`${this.getResourceUrl()}bulk_delete/`, {
mail_ids: mailIds,
})
}
}

View File

@@ -51,7 +51,6 @@ import {
check, check,
check2All, check2All,
checkAll, checkAll,
checkCircle,
checkCircleFill, checkCircleFill,
checkLg, checkLg,
chevronDoubleLeft, chevronDoubleLeft,
@@ -61,7 +60,6 @@ import {
clipboardCheck, clipboardCheck,
clipboardCheckFill, clipboardCheckFill,
clipboardFill, clipboardFill,
clockHistory,
dash, dash,
dashCircle, dashCircle,
diagram3, diagram3,
@@ -80,6 +78,7 @@ import {
fileEarmarkFill, fileEarmarkFill,
fileEarmarkLock, fileEarmarkLock,
fileEarmarkMinus, fileEarmarkMinus,
fileEarmarkPlus,
fileEarmarkRichtext, fileEarmarkRichtext,
fileText, fileText,
files, files,
@@ -96,6 +95,7 @@ import {
house, house,
infoCircle, infoCircle,
journals, journals,
layers,
link, link,
listNested, listNested,
listTask, listTask,
@@ -265,7 +265,6 @@ const icons = {
check, check,
check2All, check2All,
checkAll, checkAll,
checkCircle,
checkCircleFill, checkCircleFill,
checkLg, checkLg,
chevronDoubleLeft, chevronDoubleLeft,
@@ -275,7 +274,6 @@ const icons = {
clipboardCheck, clipboardCheck,
clipboardCheckFill, clipboardCheckFill,
clipboardFill, clipboardFill,
clockHistory,
dash, dash,
dashCircle, dashCircle,
diagram3, diagram3,
@@ -294,6 +292,7 @@ const icons = {
fileEarmarkFill, fileEarmarkFill,
fileEarmarkLock, fileEarmarkLock,
fileEarmarkMinus, fileEarmarkMinus,
fileEarmarkPlus,
fileEarmarkRichtext, fileEarmarkRichtext,
files, files,
fileText, fileText,
@@ -310,6 +309,7 @@ const icons = {
house, house,
infoCircle, infoCircle,
journals, journals,
layers,
link, link,
listNested, listNested,
listTask, listTask,

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -333,10 +332,8 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]:
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.", f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
) )
qs = Document.objects.filter(id__in=doc_ids) qs = Document.objects.filter(id__in=doc_ids)
affected_docs: list[int] = []
import pikepdf import pikepdf
rotate_tasks = []
for doc in qs: for doc in qs:
if doc.mime_type != "application/pdf": if doc.mime_type != "application/pdf":
logger.warning( logger.warning(
@@ -344,28 +341,34 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]:
) )
continue continue
try: try:
with pikepdf.open(doc.source_path, allow_overwriting_input=True) as pdf: # Write rotated output to a temp file and create a new version via consume pipeline
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{doc.id}_rotated.pdf"
)
with pikepdf.open(doc.source_path) as pdf:
for page in pdf.pages: for page in pdf.pages:
page.rotate(degrees, relative=True) page.rotate(degrees, relative=True)
pdf.save() pdf.remove_unreferenced_resources()
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest() pdf.save(filepath)
doc.save()
rotate_tasks.append( # Preserve metadata/permissions via overrides; mark as new version
update_document_content_maybe_archive_file.s( overrides = DocumentMetadataOverrides().from_document(doc)
document_id=doc.id,
consume_file.delay(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
head_version_id=doc.id,
), ),
overrides,
) )
logger.info( logger.info(
f"Rotated document {doc.id} by {degrees} degrees", f"Queued new rotated version for document {doc.id} by {degrees} degrees",
) )
affected_docs.append(doc.id)
except Exception as e: except Exception as e:
logger.exception(f"Error rotating document {doc.id}: {e}") logger.exception(f"Error rotating document {doc.id}: {e}")
if len(affected_docs) > 0:
bulk_update_task = bulk_update_documents.si(document_ids=affected_docs)
chord(header=rotate_tasks, body=bulk_update_task).delay()
return "OK" return "OK"
@@ -528,19 +531,31 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
import pikepdf import pikepdf
try: try:
with pikepdf.open(doc.source_path, allow_overwriting_input=True) as pdf: # Produce edited PDF to a temp file and create a new version
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{doc.id}_pages_deleted.pdf"
)
with pikepdf.open(doc.source_path) as pdf:
offset = 1 # pages are 1-indexed offset = 1 # pages are 1-indexed
for page_num in pages: for page_num in pages:
pdf.pages.remove(pdf.pages[page_num - offset]) pdf.pages.remove(pdf.pages[page_num - offset])
offset += 1 # remove() changes the index of the pages offset += 1 # remove() changes the index of the pages
pdf.remove_unreferenced_resources() pdf.remove_unreferenced_resources()
pdf.save() pdf.save(filepath)
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
if doc.page_count is not None: overrides = DocumentMetadataOverrides().from_document(doc)
doc.page_count = doc.page_count - len(pages) consume_file.delay(
doc.save() ConsumableDocument(
update_document_content_maybe_archive_file.delay(document_id=doc.id) source=DocumentSource.ConsumeFolder,
logger.info(f"Deleted pages {pages} from document {doc.id}") original_file=filepath,
head_version_id=doc.id,
),
overrides,
)
logger.info(
f"Queued new version for document {doc.id} after deleting pages {pages}",
)
except Exception as e: except Exception as e:
logger.exception(f"Error deleting pages from document {doc.id}: {e}") logger.exception(f"Error deleting pages from document {doc.id}: {e}")
@@ -592,17 +607,29 @@ def edit_pdf(
dst.pages[-1].rotate(op["rotate"], relative=True) dst.pages[-1].rotate(op["rotate"], relative=True)
if update_document: if update_document:
temp_path = doc.source_path.with_suffix(".tmp.pdf") # Create a new version from the edited PDF rather than replacing in-place
pdf = pdf_docs[0] pdf = pdf_docs[0]
pdf.remove_unreferenced_resources() pdf.remove_unreferenced_resources()
# save the edited PDF to a temporary file in case of errors filepath: Path = (
pdf.save(temp_path) Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
# replace the original document with the edited one / f"{doc.id}_edited.pdf"
temp_path.replace(doc.source_path) )
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest() pdf.save(filepath)
doc.page_count = len(pdf.pages) overrides = (
doc.save() DocumentMetadataOverrides().from_document(doc)
update_document_content_maybe_archive_file.delay(document_id=doc.id) if include_metadata
else DocumentMetadataOverrides()
)
if user is not None:
overrides.owner_id = user.id
consume_file.delay(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
head_version_id=doc.id,
),
overrides,
)
else: else:
consume_tasks = [] consume_tasks = []
overrides = ( overrides = (

View File

@@ -14,6 +14,51 @@ from documents.classifier import DocumentClassifier
from documents.models import Document from documents.models import Document
def _resolve_effective_doc(pk: int, request) -> Document | None:
"""
Resolve which Document row should be considered for caching keys:
- If a version is requested, use that version
- If pk is a head doc, use its newest child version if present, else the head.
- Else, pk is a version, use that version.
Returns None if resolution fails (treat as no-cache).
"""
try:
request_doc = Document.objects.only("id", "head_version_id").get(pk=pk)
except Document.DoesNotExist:
return None
head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.objects.only("id").get(id=request_doc.head_version_id)
)
version_param = (
request.query_params.get("version")
if hasattr(request, "query_params")
else None
)
if version_param:
try:
version_id = int(version_param)
candidate = Document.objects.only("id", "head_version_id").get(
id=version_id,
)
if candidate.id != head_doc.id and candidate.head_version_id != head_doc.id:
return None
return candidate
except Exception:
return None
# Default behavior: if pk is a head doc, prefer its newest child version
if request_doc.head_version_id is None:
latest = head_doc.versions.only("id").order_by("id").last()
return latest or head_doc
# pk is already a version
return request_doc
def suggestions_etag(request, pk: int) -> str | None: def suggestions_etag(request, pk: int) -> str | None:
""" """
Returns an optional string for the ETag, allowing browser caching of Returns an optional string for the ETag, allowing browser caching of
@@ -71,11 +116,10 @@ def metadata_etag(request, pk: int) -> str | None:
Metadata is extracted from the original file, so use its checksum as the Metadata is extracted from the original file, so use its checksum as the
ETag ETag
""" """
try: doc = _resolve_effective_doc(pk, request)
doc = Document.objects.only("checksum").get(pk=pk) if doc is None:
return doc.checksum
except Document.DoesNotExist: # pragma: no cover
return None return None
return doc.checksum
return None return None
@@ -85,11 +129,10 @@ def metadata_last_modified(request, pk: int) -> datetime | None:
not the modification of the original file, but of the database object, but might as well not the modification of the original file, but of the database object, but might as well
error on the side of more cautious error on the side of more cautious
""" """
try: doc = _resolve_effective_doc(pk, request)
doc = Document.objects.only("modified").get(pk=pk) if doc is None:
return doc.modified
except Document.DoesNotExist: # pragma: no cover
return None return None
return doc.modified
return None return None
@@ -97,15 +140,15 @@ def preview_etag(request, pk: int) -> str | None:
""" """
ETag for the document preview, using the original or archive checksum, depending on the request ETag for the document preview, using the original or archive checksum, depending on the request
""" """
try: doc = _resolve_effective_doc(pk, request)
doc = Document.objects.only("checksum", "archive_checksum").get(pk=pk) if doc is None:
return None
use_original = ( use_original = (
"original" in request.query_params hasattr(request, "query_params")
and "original" in request.query_params
and request.query_params["original"] == "true" and request.query_params["original"] == "true"
) )
return doc.checksum if use_original else doc.archive_checksum return doc.checksum if use_original else doc.archive_checksum
except Document.DoesNotExist: # pragma: no cover
return None
return None return None
@@ -114,11 +157,10 @@ def preview_last_modified(request, pk: int) -> datetime | None:
Uses the documents modified time to set the Last-Modified header. Not strictly Uses the documents modified time to set the Last-Modified header. Not strictly
speaking correct, but close enough and quick speaking correct, but close enough and quick
""" """
try: doc = _resolve_effective_doc(pk, request)
doc = Document.objects.only("modified").get(pk=pk) if doc is None:
return doc.modified
except Document.DoesNotExist: # pragma: no cover
return None return None
return doc.modified
return None return None
@@ -128,10 +170,13 @@ def thumbnail_last_modified(request, pk: int) -> datetime | None:
Cache should be (slightly?) faster than filesystem Cache should be (slightly?) faster than filesystem
""" """
try: try:
doc = Document.objects.only("storage_type").get(pk=pk) doc = _resolve_effective_doc(pk, request)
if doc is None:
return None
if not doc.thumbnail_path.exists(): if not doc.thumbnail_path.exists():
return None return None
doc_key = get_thumbnail_modified_key(pk) # Use the effective document id for cache key
doc_key = get_thumbnail_modified_key(doc.id)
cache_hit = cache.get(doc_key) cache_hit = cache.get(doc_key)
if cache_hit is not None: if cache_hit is not None:

View File

@@ -113,6 +113,12 @@ class ConsumerPluginMixin:
self.filename = self.metadata.filename or self.input_doc.original_file.name self.filename = self.metadata.filename or self.input_doc.original_file.name
if input_doc.head_version_id:
self.log.debug(f"Document head version id: {input_doc.head_version_id}")
head_version = Document.objects.get(pk=input_doc.head_version_id)
version_index = head_version.versions.count()
self.filename += f"_v{version_index}"
def _send_progress( def _send_progress(
self, self,
current_progress: int, current_progress: int,
@@ -470,6 +476,38 @@ class ConsumerPlugin(
try: try:
with transaction.atomic(): with transaction.atomic():
# store the document. # store the document.
if self.input_doc.head_version_id:
# If this is a new version of an existing document, we need
# to make sure we're not creating a new document, but updating
# the existing one.
original_document = Document.objects.get(
pk=self.input_doc.head_version_id,
)
self.log.debug("Saving record for updated version to database")
original_document.pk = None
original_document.head_version = Document.objects.get(
pk=self.input_doc.head_version_id,
)
file_for_checksum = (
self.unmodified_original
if self.unmodified_original is not None
else self.working_copy
)
original_document.checksum = hashlib.md5(
file_for_checksum.read_bytes(),
).hexdigest()
original_document.content = text
original_document.page_count = page_count
original_document.mime_type = mime_type
original_document.original_filename = self.filename
# Clear unique file path fields so they can be generated uniquely later
original_document.filename = None
original_document.archive_filename = None
original_document.archive_checksum = None
original_document.modified = timezone.now()
original_document.save()
document = original_document
else:
document = self._store( document = self._store(
text=text, text=text,
date=date, date=date,

View File

@@ -156,6 +156,7 @@ class ConsumableDocument:
source: DocumentSource source: DocumentSource
original_file: Path original_file: Path
head_version_id: int | None = None
mailrule_id: int | None = None mailrule_id: int | None = None
mime_type: str = dataclasses.field(init=False, default=None) mime_type: str = dataclasses.field(init=False, default=None)

View File

@@ -82,13 +82,6 @@ def _is_ignored(filepath: Path) -> bool:
def _consume(filepath: Path) -> None: def _consume(filepath: Path) -> None:
# Check permissions early
try:
filepath.stat()
except (PermissionError, OSError):
logger.warning(f"Not consuming file {filepath}: Permission denied.")
return
if filepath.is_dir() or _is_ignored(filepath): if filepath.is_dir() or _is_ignored(filepath):
return return
@@ -330,12 +323,7 @@ class Command(BaseCommand):
# Also make sure the file exists still, some scanners might write a # Also make sure the file exists still, some scanners might write a
# temporary file first # temporary file first
try:
file_still_exists = filepath.exists() and filepath.is_file() file_still_exists = filepath.exists() and filepath.is_file()
except (PermissionError, OSError): # pragma: no cover
# If we can't check, let it fail in the _consume function
file_still_exists = True
continue
if waited_long_enough and file_still_exists: if waited_long_enough and file_still_exists:
_consume(filepath) _consume(filepath)

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.1.6 on 2025-02-26 17:08
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more"),
]
operations = [
migrations.AddField(
model_name="document",
name="head_version",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="documents.document",
verbose_name="head version of document",
),
),
]

View File

@@ -313,6 +313,15 @@ class Document(SoftDeleteModel, ModelWithOwner):
), ),
) )
head_version = models.ForeignKey(
"self",
blank=True,
null=True,
related_name="versions",
on_delete=models.CASCADE,
verbose_name=_("head version of document"),
)
class Meta: class Meta:
ordering = ("-created",) ordering = ("-created",)
verbose_name = _("document") verbose_name = _("document")

View File

@@ -974,6 +974,8 @@ class DocumentSerializer(
page_count = SerializerMethodField() page_count = SerializerMethodField()
notes = NotesSerializer(many=True, required=False, read_only=True) notes = NotesSerializer(many=True, required=False, read_only=True)
head_version = serializers.PrimaryKeyRelatedField(read_only=True)
versions = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
custom_fields = CustomFieldInstanceSerializer( custom_fields = CustomFieldInstanceSerializer(
many=True, many=True,
@@ -1016,6 +1018,10 @@ class DocumentSerializer(
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"], request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
) )
if doc.get("versions") is not None:
doc["versions"] = sorted(doc["versions"], reverse=True)
doc["versions"].append(doc["id"])
if api_version < 9: if api_version < 9:
# provide created as a datetime for backwards compatibility # provide created as a datetime for backwards compatibility
from django.utils import timezone from django.utils import timezone
@@ -1184,6 +1190,8 @@ class DocumentSerializer(
"remove_inbox_tags", "remove_inbox_tags",
"page_count", "page_count",
"mime_type", "mime_type",
"head_version",
"versions",
) )
list_serializer_class = OwnedObjectListSerializer list_serializer_class = OwnedObjectListSerializer
@@ -1867,6 +1875,15 @@ class PostDocumentSerializer(serializers.Serializer):
return created.date() return created.date()
class DocumentVersionSerializer(serializers.Serializer):
document = serializers.FileField(
label="Document",
write_only=True,
)
validate_document = PostDocumentSerializer().validate_document
class BulkDownloadSerializer(DocumentListSerializer): class BulkDownloadSerializer(DocumentListSerializer):
content = serializers.ChoiceField( content = serializers.ChoiceField(
choices=["archive", "originals", "both"], choices=["archive", "originals", "both"],

View File

@@ -145,13 +145,17 @@ def consume_file(
if overrides is None: if overrides is None:
overrides = DocumentMetadataOverrides() overrides = DocumentMetadataOverrides()
plugins: list[type[ConsumeTaskPlugin]] = [ plugins: list[type[ConsumeTaskPlugin]] = (
[ConsumerPreflightPlugin, ConsumerPlugin]
if input_doc.head_version_id is not None
else [
ConsumerPreflightPlugin, ConsumerPreflightPlugin,
CollatePlugin, CollatePlugin,
BarcodePlugin, BarcodePlugin,
WorkflowTriggerPlugin, WorkflowTriggerPlugin,
ConsumerPlugin, ConsumerPlugin,
] ]
)
with ( with (
ProgressManager( ProgressManager(

View File

@@ -787,10 +787,8 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_consume_file.assert_not_called() mock_consume_file.assert_not_called()
@mock.patch("documents.tasks.bulk_update_documents.si") @mock.patch("documents.tasks.consume_file.delay")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s") def test_rotate(self, mock_consume_delay):
@mock.patch("celery.chord.delay")
def test_rotate(self, mock_chord, mock_update_document, mock_update_documents):
""" """
GIVEN: GIVEN:
- Existing documents - Existing documents
@@ -801,19 +799,22 @@ class TestPDFActions(DirectoriesMixin, TestCase):
""" """
doc_ids = [self.doc1.id, self.doc2.id] doc_ids = [self.doc1.id, self.doc2.id]
result = bulk_edit.rotate(doc_ids, 90) result = bulk_edit.rotate(doc_ids, 90)
self.assertEqual(mock_update_document.call_count, 2) self.assertEqual(mock_consume_delay.call_count, 2)
mock_update_documents.assert_called_once() for call, expected_id in zip(
mock_chord.assert_called_once() mock_consume_delay.call_args_list,
doc_ids,
):
consumable, overrides = call.args
self.assertEqual(consumable.head_version_id, expected_id)
self.assertIsNotNone(overrides)
self.assertEqual(result, "OK") self.assertEqual(result, "OK")
@mock.patch("documents.tasks.bulk_update_documents.si") @mock.patch("documents.tasks.consume_file.delay")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
@mock.patch("pikepdf.Pdf.save") @mock.patch("pikepdf.Pdf.save")
def test_rotate_with_error( def test_rotate_with_error(
self, self,
mock_pdf_save, mock_pdf_save,
mock_update_archive_file, mock_consume_delay,
mock_update_documents,
): ):
""" """
GIVEN: GIVEN:
@@ -832,16 +833,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
error_str = cm.output[0] error_str = cm.output[0]
expected_str = "Error rotating document" expected_str = "Error rotating document"
self.assertIn(expected_str, error_str) self.assertIn(expected_str, error_str)
mock_update_archive_file.assert_not_called() mock_consume_delay.assert_not_called()
@mock.patch("documents.tasks.bulk_update_documents.si") @mock.patch("documents.tasks.consume_file.delay")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
@mock.patch("celery.chord.delay")
def test_rotate_non_pdf( def test_rotate_non_pdf(
self, self,
mock_chord, mock_consume_delay,
mock_update_document,
mock_update_documents,
): ):
""" """
GIVEN: GIVEN:
@@ -856,14 +853,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
output_str = cm.output[1] output_str = cm.output[1]
expected_str = "Document 4 is not a PDF, skipping rotation" expected_str = "Document 4 is not a PDF, skipping rotation"
self.assertIn(expected_str, output_str) self.assertIn(expected_str, output_str)
self.assertEqual(mock_update_document.call_count, 1) self.assertEqual(mock_consume_delay.call_count, 1)
mock_update_documents.assert_called_once() consumable, overrides = mock_consume_delay.call_args[0]
mock_chord.assert_called_once() self.assertEqual(consumable.head_version_id, self.doc2.id)
self.assertIsNotNone(overrides)
self.assertEqual(result, "OK") self.assertEqual(result, "OK")
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay") @mock.patch("documents.tasks.consume_file.delay")
@mock.patch("pikepdf.Pdf.save") @mock.patch("pikepdf.Pdf.save")
def test_delete_pages(self, mock_pdf_save, mock_update_archive_file): @mock.patch("documents.data_models.magic.from_file", return_value="application/pdf")
def test_delete_pages(self, mock_magic, mock_pdf_save, mock_consume_delay):
""" """
GIVEN: GIVEN:
- Existing documents - Existing documents
@@ -871,24 +870,22 @@ class TestPDFActions(DirectoriesMixin, TestCase):
- Delete pages action is called with 1 document and 2 pages - Delete pages action is called with 1 document and 2 pages
THEN: THEN:
- Save should be called once - Save should be called once
- Archive file should be updated once - A new version should be enqueued via consume_file
- The document's page_count should be reduced by the number of deleted pages
""" """
doc_ids = [self.doc2.id] doc_ids = [self.doc2.id]
initial_page_count = self.doc2.page_count
pages = [1, 3] pages = [1, 3]
result = bulk_edit.delete_pages(doc_ids, pages) result = bulk_edit.delete_pages(doc_ids, pages)
mock_pdf_save.assert_called_once() mock_pdf_save.assert_called_once()
mock_update_archive_file.assert_called_once() mock_consume_delay.assert_called_once()
consumable, overrides = mock_consume_delay.call_args[0]
self.assertEqual(consumable.head_version_id, self.doc2.id)
self.assertTrue(str(consumable.original_file).endswith("_pages_deleted.pdf"))
self.assertIsNotNone(overrides)
self.assertEqual(result, "OK") self.assertEqual(result, "OK")
expected_page_count = initial_page_count - len(pages) @mock.patch("documents.tasks.consume_file.delay")
self.doc2.refresh_from_db()
self.assertEqual(self.doc2.page_count, expected_page_count)
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
@mock.patch("pikepdf.Pdf.save") @mock.patch("pikepdf.Pdf.save")
def test_delete_pages_with_error(self, mock_pdf_save, mock_update_archive_file): def test_delete_pages_with_error(self, mock_pdf_save, mock_consume_delay):
""" """
GIVEN: GIVEN:
- Existing documents - Existing documents
@@ -897,7 +894,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
- PikePDF raises an error - PikePDF raises an error
THEN: THEN:
- Save should be called once - Save should be called once
- Archive file should not be updated - No new version should be enqueued
""" """
mock_pdf_save.side_effect = Exception("Error saving PDF") mock_pdf_save.side_effect = Exception("Error saving PDF")
doc_ids = [self.doc2.id] doc_ids = [self.doc2.id]
@@ -908,7 +905,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
error_str = cm.output[0] error_str = cm.output[0]
expected_str = "Error deleting pages from document" expected_str = "Error deleting pages from document"
self.assertIn(expected_str, error_str) self.assertIn(expected_str, error_str)
mock_update_archive_file.assert_not_called() mock_consume_delay.assert_not_called()
@mock.patch("documents.bulk_edit.group") @mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s") @mock.patch("documents.tasks.consume_file.s")
@@ -968,21 +965,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertEqual(result, "OK") self.assertEqual(result, "OK")
mock_chord.assert_called_once() mock_chord.assert_called_once()
@mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay") @mock.patch("documents.tasks.consume_file.delay")
def test_edit_pdf_with_update_document(self, mock_update_document): def test_edit_pdf_with_update_document(self, mock_consume_delay):
""" """
GIVEN: GIVEN:
- A single existing PDF document - A single existing PDF document
WHEN: WHEN:
- edit_pdf is called with update_document=True and a single output - edit_pdf is called with update_document=True and a single output
THEN: THEN:
- The original document is updated in-place - A version update is enqueued targeting the existing document
- The update_document_content_maybe_archive_file task is triggered
""" """
doc_ids = [self.doc2.id] doc_ids = [self.doc2.id]
operations = [{"page": 1}, {"page": 2}] operations = [{"page": 1}, {"page": 2}]
original_checksum = self.doc2.checksum
original_page_count = self.doc2.page_count
result = bulk_edit.edit_pdf( result = bulk_edit.edit_pdf(
doc_ids, doc_ids,
@@ -992,10 +986,11 @@ class TestPDFActions(DirectoriesMixin, TestCase):
) )
self.assertEqual(result, "OK") self.assertEqual(result, "OK")
self.doc2.refresh_from_db() mock_consume_delay.assert_called_once()
self.assertNotEqual(self.doc2.checksum, original_checksum) consumable, overrides = mock_consume_delay.call_args[0]
self.assertNotEqual(self.doc2.page_count, original_page_count) self.assertEqual(consumable.head_version_id, self.doc2.id)
mock_update_document.assert_called_once_with(document_id=self.doc2.id) self.assertTrue(str(consumable.original_file).endswith("_edited.pdf"))
self.assertIsNotNone(overrides)
@mock.patch("documents.bulk_edit.group") @mock.patch("documents.bulk_edit.group")
@mock.patch("documents.tasks.consume_file.s") @mock.patch("documents.tasks.consume_file.s")

View File

@@ -209,26 +209,6 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
# assert that we have an error logged with this invalid file. # assert that we have an error logged with this invalid file.
error_logger.assert_called_once() error_logger.assert_called_once()
@mock.patch("documents.management.commands.document_consumer.logger.warning")
def test_permission_error_on_prechecks(self, warning_logger):
filepath = Path(self.dirs.consumption_dir) / "selinux.txt"
filepath.touch()
original_stat = Path.stat
def raising_stat(self, *args, **kwargs):
if self == filepath:
raise PermissionError("Permission denied")
return original_stat(self, *args, **kwargs)
with mock.patch("pathlib.Path.stat", new=raising_stat):
document_consumer._consume(filepath)
warning_logger.assert_called_once()
(args, _) = warning_logger.call_args
self.assertIn("Permission denied", args[0])
self.consume_file_mock.assert_not_called()
@override_settings(CONSUMPTION_DIR="does_not_exist") @override_settings(CONSUMPTION_DIR="does_not_exist")
def test_consumption_directory_invalid(self): def test_consumption_directory_invalid(self):
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot") self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")

View File

@@ -147,6 +147,7 @@ from documents.serialisers import CustomFieldSerializer
from documents.serialisers import DocumentListSerializer from documents.serialisers import DocumentListSerializer
from documents.serialisers import DocumentSerializer from documents.serialisers import DocumentSerializer
from documents.serialisers import DocumentTypeSerializer from documents.serialisers import DocumentTypeSerializer
from documents.serialisers import DocumentVersionSerializer
from documents.serialisers import NotesSerializer from documents.serialisers import NotesSerializer
from documents.serialisers import PostDocumentSerializer from documents.serialisers import PostDocumentSerializer
from documents.serialisers import RunTaskViewSerializer from documents.serialisers import RunTaskViewSerializer
@@ -567,7 +568,7 @@ class DocumentViewSet(
GenericViewSet, GenericViewSet,
): ):
model = Document model = Document
queryset = Document.objects.annotate(num_notes=Count("notes")) queryset = Document.objects.all()
serializer_class = DocumentSerializer serializer_class = DocumentSerializer
pagination_class = StandardPagination pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
@@ -596,7 +597,8 @@ class DocumentViewSet(
def get_queryset(self): def get_queryset(self):
return ( return (
Document.objects.distinct() Document.objects.filter(head_version__isnull=True)
.distinct()
.order_by("-created") .order_by("-created")
.annotate(num_notes=Count("notes")) .annotate(num_notes=Count("notes"))
.select_related("correspondent", "storage_path", "document_type", "owner") .select_related("correspondent", "storage_path", "document_type", "owner")
@@ -658,18 +660,55 @@ class DocumentViewSet(
and request.query_params["original"] == "true" and request.query_params["original"] == "true"
) )
def _resolve_file_doc(self, head_doc: Document, request):
version_param = request.query_params.get("version")
if version_param:
try:
version_id = int(version_param)
except (TypeError, ValueError):
raise NotFound("Invalid version parameter")
try:
candidate = Document.global_objects.select_related("owner").get(
id=version_id,
)
except Document.DoesNotExist:
raise Http404
if candidate.id != head_doc.id and candidate.head_version_id != head_doc.id:
raise Http404
return candidate
latest = head_doc.versions.order_by("id").last()
return latest or head_doc
def file_response(self, pk, request, disposition): def file_response(self, pk, request, disposition):
doc = Document.global_objects.select_related("owner").get(id=pk) request_doc = Document.global_objects.select_related("owner").get(id=pk)
head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.global_objects.select_related("owner").get(
id=request_doc.head_version_id,
)
)
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
doc, head_doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
# If a version is explicitly requested, use it. Otherwise:
# - if pk is a head document: serve newest version
# - if pk is a version: serve that version
if "version" in request.query_params:
file_doc = self._resolve_file_doc(head_doc, request)
else:
file_doc = (
self._resolve_file_doc(head_doc, request)
if request_doc.head_version_id is None
else request_doc
)
return serve_file( return serve_file(
doc=doc, doc=file_doc,
use_archive=not self.original_requested(request) use_archive=not self.original_requested(request)
and doc.has_archive_version, and file_doc.has_archive_version,
disposition=disposition, disposition=disposition,
) )
@@ -704,16 +743,33 @@ class DocumentViewSet(
) )
def metadata(self, request, pk=None): def metadata(self, request, pk=None):
try: try:
doc = Document.objects.select_related("owner").get(pk=pk) request_doc = Document.objects.select_related("owner").get(pk=pk)
head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.objects.select_related("owner").get(
id=request_doc.head_version_id,
)
)
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
doc, head_doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404
# Choose the effective document (newest version by default, or explicit via ?version=)
if "version" in request.query_params:
doc = self._resolve_file_doc(head_doc, request)
else:
doc = (
self._resolve_file_doc(head_doc, request)
if request_doc.head_version_id is None
else request_doc
)
document_cached_metadata = get_metadata_cache(doc.pk) document_cached_metadata = get_metadata_cache(doc.pk)
archive_metadata = None archive_metadata = None
@@ -815,8 +871,36 @@ class DocumentViewSet(
) )
def preview(self, request, pk=None): def preview(self, request, pk=None):
try: try:
response = self.file_response(pk, request, "inline") request_doc = Document.objects.select_related("owner").get(id=pk)
return response head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.objects.select_related("owner").get(
id=request_doc.head_version_id,
)
)
if request.user is not None and not has_perms_owner_aware(
request.user,
"view_document",
head_doc,
):
return HttpResponseForbidden("Insufficient permissions")
if "version" in request.query_params:
file_doc = self._resolve_file_doc(head_doc, request)
else:
file_doc = (
self._resolve_file_doc(head_doc, request)
if request_doc.head_version_id is None
else request_doc
)
return serve_file(
doc=file_doc,
use_archive=not self.original_requested(request)
and file_doc.has_archive_version,
disposition="inline",
)
except (FileNotFoundError, Document.DoesNotExist): except (FileNotFoundError, Document.DoesNotExist):
raise Http404 raise Http404
@@ -825,17 +909,32 @@ class DocumentViewSet(
@method_decorator(last_modified(thumbnail_last_modified)) @method_decorator(last_modified(thumbnail_last_modified))
def thumb(self, request, pk=None): def thumb(self, request, pk=None):
try: try:
doc = Document.objects.select_related("owner").get(id=pk) request_doc = Document.objects.select_related("owner").get(id=pk)
head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.objects.select_related("owner").get(
id=request_doc.head_version_id,
)
)
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
doc, head_doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
if doc.storage_type == Document.STORAGE_TYPE_GPG: if "version" in request.query_params:
handle = GnuPG.decrypted(doc.thumbnail_file) file_doc = self._resolve_file_doc(head_doc, request)
else: else:
handle = doc.thumbnail_file file_doc = (
self._resolve_file_doc(head_doc, request)
if request_doc.head_version_id is None
else request_doc
)
if file_doc.storage_type == Document.STORAGE_TYPE_GPG:
handle = GnuPG.decrypted(file_doc.thumbnail_file)
else:
handle = file_doc.thumbnail_file
return HttpResponse(handle, content_type="image/webp") return HttpResponse(handle, content_type="image/webp")
except (FileNotFoundError, Document.DoesNotExist): except (FileNotFoundError, Document.DoesNotExist):
@@ -1103,6 +1202,56 @@ class DocumentViewSet(
"Error emailing document, check logs for more detail.", "Error emailing document, check logs for more detail.",
) )
@action(methods=["post"], detail=True)
def update_version(self, request, pk=None):
serializer = DocumentVersionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
doc = Document.objects.select_related("owner").get(pk=pk)
if request.user is not None and not has_perms_owner_aware(
request.user,
"change_document",
doc,
):
return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist:
raise Http404
try:
doc_name, doc_data = serializer.validated_data.get("document")
t = int(mktime(datetime.now().timetuple()))
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
temp_file_path = Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / Path(
pathvalidate.sanitize_filename(doc_name),
)
temp_file_path.write_bytes(doc_data)
os.utime(temp_file_path, times=(t, t))
input_doc = ConsumableDocument(
source=DocumentSource.ApiUpload,
original_file=temp_file_path,
head_version_id=doc.pk,
)
async_task = consume_file.delay(
input_doc,
)
logger.debug(
f"Updated document {doc.id} with new version",
)
return Response(async_task.id)
except Exception as e:
logger.warning(f"An error occurred updating document: {e!s}")
return HttpResponseServerError(
"Error updating document, check logs for more detail.",
)
@extend_schema_view( @extend_schema_view(
list=extend_schema( list=extend_schema(

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-22 18:20+0000\n" "POT-Creation-Date: 2025-09-17 22:44+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@@ -1827,7 +1827,7 @@ msgstr ""
msgid "Chinese Traditional" msgid "Chinese Traditional"
msgstr "" msgstr ""
#: paperless/urls.py:370 #: paperless/urls.py:368
msgid "Paperless-ngx administration" msgid "Paperless-ngx administration"
msgstr "" msgstr ""

View File

@@ -922,7 +922,7 @@ CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"]
CELERY_BEAT_SCHEDULE = _parse_beat_schedule() CELERY_BEAT_SCHEDULE = _parse_beat_schedule()
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename
CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db") CELERY_BEAT_SCHEDULE_FILENAME = DATA_DIR / "celerybeat-schedule.db"
# Cachalot: Database read cache. # Cachalot: Database read cache.

View File

@@ -57,7 +57,6 @@ from paperless.views import UserViewSet
from paperless_mail.views import MailAccountViewSet from paperless_mail.views import MailAccountViewSet
from paperless_mail.views import MailRuleViewSet from paperless_mail.views import MailRuleViewSet
from paperless_mail.views import OauthCallbackView from paperless_mail.views import OauthCallbackView
from paperless_mail.views import ProcessedMailViewSet
api_router = DefaultRouter() api_router = DefaultRouter()
api_router.register(r"correspondents", CorrespondentViewSet) api_router.register(r"correspondents", CorrespondentViewSet)
@@ -78,7 +77,6 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet)
api_router.register(r"workflows", WorkflowViewSet) api_router.register(r"workflows", WorkflowViewSet)
api_router.register(r"custom_fields", CustomFieldViewSet) api_router.register(r"custom_fields", CustomFieldViewSet)
api_router.register(r"config", ApplicationConfigurationViewSet) api_router.register(r"config", ApplicationConfigurationViewSet)
api_router.register(r"processed_mail", ProcessedMailViewSet)
urlpatterns = [ urlpatterns = [

View File

@@ -1,12 +0,0 @@
from django_filters import FilterSet
from paperless_mail.models import ProcessedMail
class ProcessedMailFilterSet(FilterSet):
class Meta:
model = ProcessedMail
fields = {
"rule": ["exact"],
"status": ["exact"],
}

View File

@@ -6,7 +6,6 @@ from documents.serialisers import OwnedObjectSerializer
from documents.serialisers import TagsField from documents.serialisers import TagsField
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail
class ObfuscatedPasswordField(serializers.CharField): class ObfuscatedPasswordField(serializers.CharField):
@@ -131,20 +130,3 @@ class MailRuleSerializer(OwnedObjectSerializer):
if value > 36500: # ~100 years if value > 36500: # ~100 years
raise serializers.ValidationError("Maximum mail age is unreasonably large.") raise serializers.ValidationError("Maximum mail age is unreasonably large.")
return value return value
class ProcessedMailSerializer(OwnedObjectSerializer):
class Meta:
model = ProcessedMail
fields = [
"id",
"owner",
"rule",
"folder",
"uid",
"subject",
"received",
"processed",
"status",
"error",
]

View File

@@ -3,7 +3,6 @@ from unittest import mock
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -14,7 +13,6 @@ from documents.models import Tag
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail
from paperless_mail.tests.test_mail import BogusMailBox from paperless_mail.tests.test_mail import BogusMailBox
@@ -723,285 +721,3 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("maximum_age", response.data) self.assertIn("maximum_age", response.data)
class TestAPIProcessedMails(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/processed_mail/"
def setUp(self):
super().setUp()
self.user = User.objects.create_user(username="temp_admin")
self.user.user_permissions.add(*Permission.objects.all())
self.user.save()
self.client.force_authenticate(user=self.user)
def test_get_processed_mails_owner_aware(self):
"""
GIVEN:
- Configured processed mails with different users
WHEN:
- API call is made to get processed mails
THEN:
- Only unowned, owned by user or granted processed mails are provided
"""
user2 = User.objects.create_user(username="temp_admin2")
account = MailAccount.objects.create(
name="Email1",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
)
rule = MailRule.objects.create(
name="Rule1",
account=account,
folder="INBOX",
filter_from="from@example.com",
order=0,
)
pm1 = ProcessedMail.objects.create(
rule=rule,
folder="INBOX",
uid="1",
subject="Subj1",
received=timezone.now(),
processed=timezone.now(),
status="SUCCESS",
error=None,
)
pm2 = ProcessedMail.objects.create(
rule=rule,
folder="INBOX",
uid="2",
subject="Subj2",
received=timezone.now(),
processed=timezone.now(),
status="FAILED",
error="err",
owner=self.user,
)
ProcessedMail.objects.create(
rule=rule,
folder="INBOX",
uid="3",
subject="Subj3",
received=timezone.now(),
processed=timezone.now(),
status="SUCCESS",
error=None,
owner=user2,
)
pm4 = ProcessedMail.objects.create(
rule=rule,
folder="INBOX",
uid="4",
subject="Subj4",
received=timezone.now(),
processed=timezone.now(),
status="SUCCESS",
error=None,
)
pm4.owner = user2
pm4.save()
assign_perm("view_processedmail", self.user, pm4)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)
returned_ids = {r["id"] for r in response.data["results"]}
self.assertSetEqual(returned_ids, {pm1.id, pm2.id, pm4.id})
def test_get_processed_mails_filter_by_rule(self):
"""
GIVEN:
- Processed mails belonging to two different rules
WHEN:
- API call is made with rule filter
THEN:
- Only processed mails for that rule are returned
"""
account = MailAccount.objects.create(
name="Email1",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
)
rule1 = MailRule.objects.create(
name="Rule1",
account=account,
folder="INBOX",
filter_from="from1@example.com",
order=0,
)
rule2 = MailRule.objects.create(
name="Rule2",
account=account,
folder="INBOX",
filter_from="from2@example.com",
order=1,
)
pm1 = ProcessedMail.objects.create(
rule=rule1,
folder="INBOX",
uid="r1-1",
subject="R1-A",
received=timezone.now(),
processed=timezone.now(),
status="SUCCESS",
error=None,
owner=self.user,
)
pm2 = ProcessedMail.objects.create(
rule=rule1,
folder="INBOX",
uid="r1-2",
subject="R1-B",
received=timezone.now(),
processed=timezone.now(),
status="FAILED",
error="e",
)
ProcessedMail.objects.create(
rule=rule2,
folder="INBOX",
uid="r2-1",
subject="R2-A",
received=timezone.now(),
processed=timezone.now(),
status="SUCCESS",
error=None,
)
response = self.client.get(f"{self.ENDPOINT}?rule={rule1.pk}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
returned_ids = {r["id"] for r in response.data["results"]}
self.assertSetEqual(returned_ids, {pm1.id, pm2.id})
def test_bulk_delete_processed_mails(self):
"""
GIVEN:
- Processed mails belonging to two different rules and different users
WHEN:
- API call is made to bulk delete some of the processed mails
THEN:
- Only the specified processed mails are deleted, respecting ownership and permissions
"""
user2 = User.objects.create_user(username="temp_admin2")
account = MailAccount.objects.create(
name="Email1",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
)
rule = MailRule.objects.create(
name="Rule1",
account=account,
folder="INBOX",
filter_from="from@example.com",
order=0,
)
# unowned and owned by self, and one with explicit object perm
pm_unowned = ProcessedMail.objects.create(
rule=rule,
folder="INBOX",
uid="u1",
subject="Unowned",
received=timezone.now(),
processed=timezone.now(),
status="SUCCESS",
error=None,
)
pm_owned = ProcessedMail.objects.create(
rule=rule,
folder="INBOX",
uid="u2",
subject="Owned",
received=timezone.now(),
processed=timezone.now(),
status="FAILED",
error="e",
owner=self.user,
)
pm_granted = ProcessedMail.objects.create(
rule=rule,
folder="INBOX",
uid="u3",
subject="Granted",
received=timezone.now(),
processed=timezone.now(),
status="SUCCESS",
error=None,
owner=user2,
)
assign_perm("delete_processedmail", self.user, pm_granted)
pm_forbidden = ProcessedMail.objects.create(
rule=rule,
folder="INBOX",
uid="u4",
subject="Forbidden",
received=timezone.now(),
processed=timezone.now(),
status="SUCCESS",
error=None,
owner=user2,
)
# Success for allowed items
response = self.client.post(
f"{self.ENDPOINT}bulk_delete/",
data={
"mail_ids": [pm_unowned.id, pm_owned.id, pm_granted.id],
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["result"], "OK")
self.assertSetEqual(
set(response.data["deleted_mail_ids"]),
{pm_unowned.id, pm_owned.id, pm_granted.id},
)
self.assertFalse(ProcessedMail.objects.filter(id=pm_unowned.id).exists())
self.assertFalse(ProcessedMail.objects.filter(id=pm_owned.id).exists())
self.assertFalse(ProcessedMail.objects.filter(id=pm_granted.id).exists())
self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists())
# 403 and not deleted
response = self.client.post(
f"{self.ENDPOINT}bulk_delete/",
data={
"mail_ids": [pm_forbidden.id],
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists())
# missing mail_ids
response = self.client.post(
f"{self.ENDPOINT}bulk_delete/",
data={"mail_ids": "not-a-list"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

View File

@@ -3,10 +3,8 @@ import logging
from datetime import timedelta from datetime import timedelta
from django.http import HttpResponseBadRequest from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.utils import timezone from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view from drf_spectacular.utils import extend_schema_view
@@ -14,29 +12,23 @@ from drf_spectacular.utils import inline_serializer
from httpx_oauth.oauth2 import GetAccessTokenError from httpx_oauth.oauth2 import GetAccessTokenError
from rest_framework import serializers from rest_framework import serializers
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ReadOnlyModelViewSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
from documents.permissions import has_perms_owner_aware
from documents.views import PassUserMixin from documents.views import PassUserMixin
from paperless.views import StandardPagination from paperless.views import StandardPagination
from paperless_mail.filters import ProcessedMailFilterSet
from paperless_mail.mail import MailError from paperless_mail.mail import MailError
from paperless_mail.mail import get_mailbox from paperless_mail.mail import get_mailbox
from paperless_mail.mail import mailbox_login from paperless_mail.mail import mailbox_login
from paperless_mail.models import MailAccount from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
from paperless_mail.models import ProcessedMail
from paperless_mail.oauth import PaperlessMailOAuth2Manager from paperless_mail.oauth import PaperlessMailOAuth2Manager
from paperless_mail.serialisers import MailAccountSerializer from paperless_mail.serialisers import MailAccountSerializer
from paperless_mail.serialisers import MailRuleSerializer from paperless_mail.serialisers import MailRuleSerializer
from paperless_mail.serialisers import ProcessedMailSerializer
from paperless_mail.tasks import process_mail_accounts from paperless_mail.tasks import process_mail_accounts
@@ -134,34 +126,6 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
return Response({"result": "OK"}) return Response({"result": "OK"})
class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = ProcessedMailSerializer
pagination_class = StandardPagination
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = ProcessedMailFilterSet
queryset = ProcessedMail.objects.all().order_by("-processed")
@action(methods=["post"], detail=False)
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")
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")
mail.delete()
return Response({"result": "OK", "deleted_mail_ids": mail_ids})
class MailRuleViewSet(ModelViewSet, PassUserMixin): class MailRuleViewSet(ModelViewSet, PassUserMixin):
model = MailRule model = MailRule