mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-30 01:32:43 -05:00
Compare commits
1 Commits
dependabot
...
feature-co
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8ea2f56925 |
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@@ -17,52 +17,11 @@ env:
|
||||
DEFAULT_PYTHON_VERSION: "3.11"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
jobs:
|
||||
detect-duplicate:
|
||||
name: Detect Duplicate Run
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- name: Check if workflow should run
|
||||
id: check
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
if (context.eventName !== 'push') {
|
||||
core.info('Not a push event; running workflow.');
|
||||
core.setOutput('should_run', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = context.ref || '';
|
||||
if (!ref.startsWith('refs/heads/')) {
|
||||
core.info('Push is not to a branch; running workflow.');
|
||||
core.setOutput('should_run', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = ref.substring('refs/heads/'.length);
|
||||
const { owner, repo } = context.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
head: `${owner}:${branch}`,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (prs.length === 0) {
|
||||
core.info(`No open PR found for ${branch}; running workflow.`);
|
||||
core.setOutput('should_run', 'true');
|
||||
} else {
|
||||
core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`);
|
||||
core.setOutput('should_run', 'false');
|
||||
}
|
||||
pre-commit:
|
||||
needs:
|
||||
- detect-duplicate
|
||||
if: needs.detect-duplicate.outputs.should_run == 'true'
|
||||
# We want to run on external PRs, but not on our own internal PRs as they'll be run
|
||||
# by the push to the branch. Without this if check, checks are duplicated since
|
||||
# internal PRs match both the push and pull_request events.
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
||||
name: Linting Checks
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -183,11 +142,13 @@ jobs:
|
||||
if: always()
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: junit.xml
|
||||
- name: Upload backend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: coverage.xml
|
||||
- name: Stop containers
|
||||
@@ -263,11 +224,13 @@ jobs:
|
||||
uses: codecov/test-results-action@v1
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/
|
||||
- name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/coverage/
|
||||
tests-frontend-e2e:
|
||||
|
1
.github/workflows/repo-maintenance.yml
vendored
1
.github/workflows/repo-maintenance.yml
vendored
@@ -241,7 +241,6 @@ jobs:
|
||||
) {
|
||||
nodes {
|
||||
id,
|
||||
createdAt,
|
||||
number,
|
||||
updatedAt,
|
||||
upvoteCount,
|
||||
|
@@ -2,11 +2,9 @@
|
||||
|
||||
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:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- Discussions with a marked answer will be automatically closed.
|
||||
- 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.
|
||||
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
||||
|
@@ -32,7 +32,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.8.22-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
|
||||
|
||||
|
@@ -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`
|
||||
|
||||
!!! 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}
|
||||
|
||||
!!! note
|
||||
|
@@ -261,10 +261,6 @@ different means. These are as follows:
|
||||
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)
|
||||
|
||||
#### 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
|
||||
|
||||
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.
|
||||
|
@@ -30,10 +30,10 @@ dependencies = [
|
||||
"django-cachalot~=2.8.0",
|
||||
"django-celery-results~=2.6.0",
|
||||
"django-compression-middleware~=0.5.0",
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-cors-headers~=4.8.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=3.2.0",
|
||||
"django-guardian~=3.1.2",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"django-treenode>=0.23.2",
|
||||
|
@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(
|
||||
/Selected 61 of 61 documents/i
|
||||
)
|
||||
await page.getByRole('button', { name: 'None' }).click()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await page.locator('pngx-document-card-small').nth(1).click()
|
||||
await page.locator('pngx-document-card-small').nth(2).click()
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -166,10 +166,13 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-group mt-3 mb-1">
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<h6 class="sidebar-heading px-3 text-muted d-flex align-items-center">
|
||||
<span i18n>Manage</span>
|
||||
<button class="btn btn-link p-2 py-0" (click)="manageCollapse.toggle()">
|
||||
<i-bs width="0.9em" height="0.9em" [name]="isManageMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
|
||||
</button>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<ul class="nav flex-column mb-2" #manageCollapse="ngbCollapse" [(ngbCollapse)]="isManageMenuCollapsed">
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||
@@ -243,10 +246,14 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-group mt-auto mb-1">
|
||||
<h6 class="sidebar-heading px-3 pt-4 text-muted">
|
||||
<h6 class="sidebar-heading px-3 pt-4 text-muted d-flex align-items-center">
|
||||
<span i18n>Administration</span>
|
||||
<button class="btn btn-link p-2 py-0" (click)="adminCollapse.toggle()">
|
||||
<i-bs width="0.9em" height="0.9em" [name]="isAdminMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
|
||||
</button>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<div class="mb-2">
|
||||
<ul class="nav flex-column" #adminCollapse="ngbCollapse" [(ngbCollapse)]="isAdminMenuCollapsed">
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||
tourAnchor="tour.settings">
|
||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||
@@ -292,6 +299,8 @@
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
@@ -356,6 +365,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main role="main" class="ms-sm-auto px-md-4"
|
||||
|
@@ -89,6 +89,8 @@ export class AppFrameComponent
|
||||
appRemoteVersion: AppRemoteVersion
|
||||
|
||||
isMenuCollapsed: boolean = true
|
||||
isManageMenuCollapsed: boolean = false
|
||||
isAdminMenuCollapsed: boolean = false
|
||||
|
||||
slimSidebarAnimating: boolean = false
|
||||
|
||||
|
@@ -177,16 +177,10 @@ export class CustomFieldEditDialogComponent
|
||||
}
|
||||
|
||||
public removeSelectOption(index: number) {
|
||||
const globalIndex =
|
||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
||||
this._allSelectOptions.splice(globalIndex, 1)
|
||||
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE)
|
||||
this.selectOptions.removeAt(index)
|
||||
this._allSelectOptions.splice(
|
||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
||||
1
|
||||
)
|
||||
const targetPage = Math.min(this.selectOptionsPage, totalPages)
|
||||
|
||||
this.selectOptionsPage = targetPage
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,21 @@
|
||||
<div class="d-flex flex-wrap gap-4">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<div class="d-flex align-items-center" role="group" aria-label="Select">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
|
||||
<label class="me-2" i18n>Select:</label>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
|
||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<label class="me-2" i18n>Edit:</label>
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||
@@ -73,14 +89,14 @@
|
||||
(apply)="setCustomFields($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||
<div class="btn-toolbar">
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
||||
</button>
|
||||
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
||||
<i-bs name="three-dots"></i-bs>
|
||||
@@ -99,6 +115,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||
@if (!awaitingDownload) {
|
||||
|
@@ -5,7 +5,3 @@
|
||||
.dropdown-menu{
|
||||
--bs-dropdown-min-width: 12rem;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@@ -1,36 +1,16 @@
|
||||
<pngx-page-header [title]="getTitle()">
|
||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||
|
||||
<div ngbDropdown class="btn-group flex-fill">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
||||
<i-bs name="text-indent-left"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||
@if (list.selected.size > 0) {
|
||||
<pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
}
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
|
||||
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
|
||||
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0">Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (list.selected.size > 0) {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
|
||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ngbDropdown class="btn-group flex-fill">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
||||
<i-bs name="card-heading"></i-bs>
|
||||
@@ -148,11 +128,6 @@
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
||||
</button>
|
||||
}
|
||||
@if (!list.isReloading && list.selected.size > 0) {
|
||||
<button class="btn btn-link py-0" (click)="list.selectNone()">
|
||||
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (list.collectionSize) {
|
||||
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
||||
|
@@ -56,7 +56,6 @@ import {
|
||||
filterRulesDiffer,
|
||||
isFullTextFilterRule,
|
||||
} from 'src/app/utils/filter-rules'
|
||||
import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
|
||||
import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component'
|
||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
||||
@@ -73,7 +72,6 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
|
||||
templateUrl: './document-list.component.html',
|
||||
styleUrls: ['./document-list.component.scss'],
|
||||
imports: [
|
||||
ClearableBadgeComponent,
|
||||
CustomFieldDisplayComponent,
|
||||
PageHeaderComponent,
|
||||
BulkEditorComponent,
|
||||
|
@@ -109,11 +109,10 @@
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col-1 d-none d-sm-block" i18n>Sort Order</div>
|
||||
<div class="col-2" i18n>Account</div>
|
||||
<div class="col-2 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-3" i18n>Actions</div>
|
||||
<div class="col d-none d-sm-block" i18n>Sort Order</div>
|
||||
<div class="col" i18n>Account</div>
|
||||
<div class="col d-none d-sm-block" i18n>Status</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -128,9 +127,9 @@
|
||||
<li class="list-group-item">
|
||||
<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-1 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-2 d-flex align-items-center d-none d-sm-flex">
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
||||
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex">
|
||||
<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 }">
|
||||
<label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
|
||||
@@ -138,12 +137,7 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)">
|
||||
<i-bs width="1em" height="1em" name="clock-history"></i-bs> <ng-container i18n>View Processed Mail</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="col">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
|
@@ -409,13 +409,4 @@ describe('MailComponent', () => {
|
||||
jest.advanceTimersByTime(200)
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
@@ -27,7 +27,6 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component'
|
||||
|
||||
@Component({
|
||||
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 {
|
||||
return this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
|
@@ -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>
|
@@ -1,8 +0,0 @@
|
||||
::ng-deep .popover {
|
||||
max-width: 350px;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
@@ -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()
|
||||
})
|
||||
})
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -71,20 +71,4 @@ describe('TagListComponent', () => {
|
||||
'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)
|
||||
})
|
||||
})
|
||||
|
@@ -62,8 +62,6 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
||||
}
|
||||
|
||||
filterData(data: Tag[]) {
|
||||
return this.nameFilter?.length
|
||||
? [...data]
|
||||
: data.filter((tag) => !tag.parent)
|
||||
return data.filter((tag) => !tag.parent)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
@@ -28,7 +28,6 @@ export enum PermissionType {
|
||||
ShareLink = '%s_sharelink',
|
||||
CustomField = '%s_customfield',
|
||||
Workflow = '%s_workflow',
|
||||
ProcessedMail = '%s_processedmail',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@@ -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({})
|
||||
})
|
||||
})
|
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
@@ -51,17 +51,17 @@ import {
|
||||
check,
|
||||
check2All,
|
||||
checkAll,
|
||||
checkCircle,
|
||||
checkCircleFill,
|
||||
checkLg,
|
||||
chevronDoubleLeft,
|
||||
chevronDoubleRight,
|
||||
chevronDown,
|
||||
chevronRight,
|
||||
chevronUp,
|
||||
clipboard,
|
||||
clipboardCheck,
|
||||
clipboardCheckFill,
|
||||
clipboardFill,
|
||||
clockHistory,
|
||||
dash,
|
||||
dashCircle,
|
||||
diagram3,
|
||||
@@ -265,17 +265,17 @@ const icons = {
|
||||
check,
|
||||
check2All,
|
||||
checkAll,
|
||||
checkCircle,
|
||||
checkCircleFill,
|
||||
checkLg,
|
||||
chevronDoubleLeft,
|
||||
chevronDoubleRight,
|
||||
chevronDown,
|
||||
chevronRight,
|
||||
chevronUp,
|
||||
clipboard,
|
||||
clipboardCheck,
|
||||
clipboardCheckFill,
|
||||
clipboardFill,
|
||||
clockHistory,
|
||||
dash,
|
||||
dashCircle,
|
||||
diagram3,
|
||||
|
@@ -164,9 +164,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
||||
mailrule_id=self.input_doc.mailrule_id,
|
||||
# Can't use same folder or the consume might grab it again
|
||||
original_file=(tmp_dir / new_document.name).resolve(),
|
||||
# Adding optional original_path for later uses in
|
||||
# workflow matching
|
||||
original_path=self.input_doc.original_file,
|
||||
),
|
||||
# All the same metadata
|
||||
self.metadata,
|
||||
|
@@ -156,7 +156,6 @@ class ConsumableDocument:
|
||||
|
||||
source: DocumentSource
|
||||
original_file: Path
|
||||
original_path: Path | None = None
|
||||
mailrule_id: int | None = None
|
||||
mime_type: str = dataclasses.field(init=False, default=None)
|
||||
|
||||
|
@@ -82,13 +82,6 @@ def _is_ignored(filepath: Path) -> bool:
|
||||
|
||||
|
||||
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):
|
||||
return
|
||||
|
||||
@@ -330,12 +323,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Also make sure the file exists still, some scanners might write a
|
||||
# temporary file first
|
||||
try:
|
||||
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:
|
||||
_consume(filepath)
|
||||
|
@@ -92,9 +92,6 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
|
||||
# doc to doc is obviously not useful
|
||||
if first_doc.pk == second_doc.pk:
|
||||
continue
|
||||
# Skip empty documents (e.g. password-protected)
|
||||
if first_doc.content.strip() == "" or second_doc.content.strip() == "":
|
||||
continue
|
||||
# Skip matching which have already been matched together
|
||||
# doc 1 to doc 2 is the same as doc 2 to doc 1
|
||||
doc_1_to_doc_2 = (first_doc.pk, second_doc.pk)
|
||||
|
@@ -314,19 +314,11 @@ def consumable_document_matches_workflow(
|
||||
trigger_matched = False
|
||||
|
||||
# Document path vs trigger path
|
||||
|
||||
# Use the original_path if set, else us the original_file
|
||||
match_against = (
|
||||
document.original_path
|
||||
if document.original_path is not None
|
||||
else document.original_file
|
||||
)
|
||||
|
||||
if (
|
||||
trigger.filter_path is not None
|
||||
and len(trigger.filter_path) > 0
|
||||
and not fnmatch(
|
||||
match_against,
|
||||
document.original_file,
|
||||
trigger.filter_path,
|
||||
)
|
||||
):
|
||||
|
@@ -614,16 +614,14 @@ class TestBarcodeNewConsume(
|
||||
self.assertIsNotFile(temp_copy)
|
||||
|
||||
# Check the split files exist
|
||||
# Check the original_path is set
|
||||
# Check the source is unchanged
|
||||
# Check the overrides are unchanged
|
||||
for (
|
||||
new_input_doc,
|
||||
new_doc_overrides,
|
||||
) in self.get_all_consume_delay_call_args():
|
||||
self.assertIsFile(new_input_doc.original_file)
|
||||
self.assertEqual(new_input_doc.original_path, temp_copy)
|
||||
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
|
||||
self.assertIsFile(new_input_doc.original_file)
|
||||
self.assertEqual(overrides, new_doc_overrides)
|
||||
|
||||
|
||||
|
@@ -209,26 +209,6 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
|
||||
# assert that we have an error logged with this invalid file.
|
||||
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")
|
||||
def test_consumption_directory_invalid(self):
|
||||
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")
|
||||
|
@@ -206,29 +206,3 @@ class TestFuzzyMatchCommand(TestCase):
|
||||
self.assertEqual(Document.objects.count(), 2)
|
||||
self.assertIsNotNone(Document.objects.get(pk=1))
|
||||
self.assertIsNotNone(Document.objects.get(pk=2))
|
||||
|
||||
def test_empty_content(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- 2 documents exist, content is empty (pw-protected)
|
||||
WHEN:
|
||||
- Command is called
|
||||
THEN:
|
||||
- No matches are found
|
||||
"""
|
||||
Document.objects.create(
|
||||
checksum="BEEFCAFE",
|
||||
title="A",
|
||||
content="",
|
||||
mime_type="application/pdf",
|
||||
filename="test.pdf",
|
||||
)
|
||||
Document.objects.create(
|
||||
checksum="DEADBEAF",
|
||||
title="A",
|
||||
content="",
|
||||
mime_type="application/pdf",
|
||||
filename="other_test.pdf",
|
||||
)
|
||||
stdout, _ = self.call_command()
|
||||
self.assertIn("No matches found", stdout)
|
||||
|
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\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"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1827,7 +1827,7 @@ msgstr ""
|
||||
msgid "Chinese Traditional"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/urls.py:370
|
||||
#: paperless/urls.py:368
|
||||
msgid "Paperless-ngx administration"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -922,7 +922,7 @@ CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"]
|
||||
CELERY_BEAT_SCHEDULE = _parse_beat_schedule()
|
||||
|
||||
# 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.
|
||||
|
@@ -57,7 +57,6 @@ from paperless.views import UserViewSet
|
||||
from paperless_mail.views import MailAccountViewSet
|
||||
from paperless_mail.views import MailRuleViewSet
|
||||
from paperless_mail.views import OauthCallbackView
|
||||
from paperless_mail.views import ProcessedMailViewSet
|
||||
|
||||
api_router = DefaultRouter()
|
||||
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"custom_fields", CustomFieldViewSet)
|
||||
api_router.register(r"config", ApplicationConfigurationViewSet)
|
||||
api_router.register(r"processed_mail", ProcessedMailViewSet)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
@@ -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"],
|
||||
}
|
@@ -6,7 +6,6 @@ from documents.serialisers import OwnedObjectSerializer
|
||||
from documents.serialisers import TagsField
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
from paperless_mail.models import ProcessedMail
|
||||
|
||||
|
||||
class ObfuscatedPasswordField(serializers.CharField):
|
||||
@@ -131,20 +130,3 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
||||
if value > 36500: # ~100 years
|
||||
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
||||
return value
|
||||
|
||||
|
||||
class ProcessedMailSerializer(OwnedObjectSerializer):
|
||||
class Meta:
|
||||
model = ProcessedMail
|
||||
fields = [
|
||||
"id",
|
||||
"owner",
|
||||
"rule",
|
||||
"folder",
|
||||
"uid",
|
||||
"subject",
|
||||
"received",
|
||||
"processed",
|
||||
"status",
|
||||
"error",
|
||||
]
|
||||
|
@@ -3,7 +3,6 @@ from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -14,7 +13,6 @@ from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
from paperless_mail.models import ProcessedMail
|
||||
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.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)
|
||||
|
@@ -3,10 +3,8 @@ import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
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 rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
||||
from documents.permissions import PaperlessObjectPermissions
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.views import PassUserMixin
|
||||
from paperless.views import StandardPagination
|
||||
from paperless_mail.filters import ProcessedMailFilterSet
|
||||
from paperless_mail.mail import MailError
|
||||
from paperless_mail.mail import get_mailbox
|
||||
from paperless_mail.mail import mailbox_login
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
from paperless_mail.models import ProcessedMail
|
||||
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
||||
from paperless_mail.serialisers import MailAccountSerializer
|
||||
from paperless_mail.serialisers import MailRuleSerializer
|
||||
from paperless_mail.serialisers import ProcessedMailSerializer
|
||||
from paperless_mail.tasks import process_mail_accounts
|
||||
|
||||
|
||||
@@ -134,34 +126,6 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
||||
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):
|
||||
model = MailRule
|
||||
|
||||
|
46
uv.lock
generated
46
uv.lock
generated
@@ -730,15 +730,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-cors-headers"
|
||||
version = "4.9.0"
|
||||
version = "4.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/8e/6225441edcfe179bf4861e9e67489e33375e0b66316c8d7b9edaae863d37/django_cors_headers-4.8.0.tar.gz", hash = "sha256:0a12a2efcd59a3cea741e44db8ab589e929949de5bc4cdf35a29c6ae77297686", size = 21425, upload-time = "2025-09-08T15:58:05.34Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/b3/29ef49d6ff7800f323f3d98cde7777b3cfdda133de8feea84cffafea4578/django_cors_headers-4.8.0-py3-none-any.whl", hash = "sha256:3b883f4c6d07848673218456a5e070d8ab51f97341c1f27d0242ca167e7272ab", size = 12804, upload-time = "2025-09-08T15:58:03.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -782,15 +782,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-guardian"
|
||||
version = "3.2.0"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/f9/bcff6a931298b9eb55e1550b55ab964fab747f594ba6d2d81cbe19736c5f/django_guardian-3.2.0.tar.gz", hash = "sha256:9e18ecd2e211b665972690c2d03d27bce0ea4932b5efac24a4bb9d526950a69e", size = 99940, upload-time = "2025-09-16T10:35:53.609Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/d3/436a44c7688fce1a978224c349ba66c95bf9103d548596b7a2694fd58c03/django_guardian-3.1.3.tar.gz", hash = "sha256:12b5e66c18c97088b0adfa033ab14be68c321c170fd3ec438898271f00a71699", size = 93571, upload-time = "2025-09-10T08:36:23.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/23/63a7d868373a73d25c4a5c2dd3cce3aaeb22fbee82560d42b6e93ba01403/django_guardian-3.2.0-py3-none-any.whl", hash = "sha256:0768565a057988a93fc4a1d93649c4a794abfd7473a8408a079cfbf83c559d77", size = 134674, upload-time = "2025-09-16T10:35:51.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/fc/6fd7b8bc7c52cbbfd1714673cfd28ff0b3fae32265c52d492ec0dee22cb8/django_guardian-3.1.3-py3-none-any.whl", hash = "sha256:90e28b40eea65c326a3a961908cc300f9e1cd69b74e88d38317a9befa167b71c", size = 127687, upload-time = "2025-09-10T08:36:22.533Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -807,14 +807,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-soft-delete"
|
||||
version = "1.0.21"
|
||||
version = "1.0.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/bf/13996c18bffee3bbcf294830c1737bfb5564164b8319c51e6714b6bdf783/django_soft_delete-1.0.21.tar.gz", hash = "sha256:542bd4650d2769105a4363ea7bb7fbdb3c28429dbaa66417160f8f4b5dc689d5", size = 21153, upload-time = "2025-09-17T08:46:30.476Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/77/44a6615a7da3ca0ddc624039d399d17d6c3503e1c2dad08b443f8d4a3570/django_soft_delete-1.0.19.tar.gz", hash = "sha256:c67ee8920e1456eca84cc59b3304ef27fa9d476b516be726ce7e1fc558502908", size = 11993, upload-time = "2025-06-19T20:32:20.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/e6/8f4fed14499c63e35ca33cf9f424ad2e14e963ec5545594d7c7dc2f710f4/django_soft_delete-1.0.21-py3-none-any.whl", hash = "sha256:dd91e671d9d431ff96f4db727ce03e7fbb4008ae4541b1d162d5d06cc9becd2a", size = 18681, upload-time = "2025-09-17T08:46:29.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/9e/f8b5a02cdcba606eb40fbe30fe0c9c7493a2c18f83ec3b4620e4e86a34d3/django_soft_delete-1.0.19-py3-none-any.whl", hash = "sha256:46aa5fab513db566d3d7a832529ed27245b5900eaaa705535bc7674055801a46", size = 10889, upload-time = "2025-06-19T20:32:19.083Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2182,10 +2182,10 @@ requires-dist = [
|
||||
{ name = "django-cachalot", specifier = "~=2.8.0" },
|
||||
{ name = "django-celery-results", specifier = "~=2.6.0" },
|
||||
{ name = "django-compression-middleware", specifier = "~=0.5.0" },
|
||||
{ name = "django-cors-headers", specifier = "~=4.9.0" },
|
||||
{ name = "django-cors-headers", specifier = "~=4.8.0" },
|
||||
{ name = "django-extensions", specifier = "~=4.1" },
|
||||
{ name = "django-filter", specifier = "~=25.1" },
|
||||
{ name = "django-guardian", specifier = "~=3.2.0" },
|
||||
{ name = "django-guardian", specifier = "~=3.1.2" },
|
||||
{ name = "django-multiselectfield", specifier = "~=1.0.1" },
|
||||
{ name = "django-soft-delete", specifier = "~=1.0.18" },
|
||||
{ name = "django-treenode", specifier = ">=0.23.2" },
|
||||
@@ -2806,15 +2806,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-rerunfailures"
|
||||
version = "16.0.1"
|
||||
version = "15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/53/a543a76f922a5337d10df22441af8bf68f1b421cadf9aedf8a77943b81f6/pytest_rerunfailures-16.0.1.tar.gz", hash = "sha256:ed4b3a6e7badb0a720ddd93f9de1e124ba99a0cb13bc88561b3c168c16062559", size = 27612, upload-time = "2025-09-02T06:48:25.193Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/78/e6e358545537a8e82c4dc91e72ec0d6f80546a3786dd27c76b06ca09db77/pytest_rerunfailures-15.1.tar.gz", hash = "sha256:c6040368abd7b8138c5b67288be17d6e5611b7368755ce0465dda0362c8ece80", size = 26981, upload-time = "2025-05-08T06:36:33.483Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/73/67dc14cda1942914e70fbb117fceaf11e259362c517bdadd76b0dd752524/pytest_rerunfailures-16.0.1-py3-none-any.whl", hash = "sha256:0bccc0e3b0e3388275c25a100f7077081318196569a121217688ed05e58984b9", size = 13610, upload-time = "2025-09-02T06:48:23.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/30/11d836ff01c938969efa319b4ebe2374ed79d28043a12bfc908577aab9f3/pytest_rerunfailures-15.1-py3-none-any.whl", hash = "sha256:f674c3594845aba8b23c78e99b1ff8068556cc6a8b277f728071fdc4f4b0b355", size = 13274, upload-time = "2025-05-08T06:36:32.029Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3781,11 +3781,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-colorama"
|
||||
version = "0.4.15.20250801"
|
||||
version = "0.4.15.20240311"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/37/af713e7d73ca44738c68814cbacf7a655aa40ddd2e8513d431ba78ace7b3/types_colorama-0.4.15.20250801.tar.gz", hash = "sha256:02565d13d68963d12237d3f330f5ecd622a3179f7b5b14ee7f16146270c357f5", size = 10437, upload-time = "2025-08-01T03:48:22.605Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/73/0fb0b9fe4964b45b2a06ed41b60c352752626db46aa0fb70a49a9e283a75/types-colorama-0.4.15.20240311.tar.gz", hash = "sha256:a28e7f98d17d2b14fb9565d32388e419f4108f557a7d939a66319969b2b99c7a", size = 5608, upload-time = "2024-03-11T02:15:51.557Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/83/6944b4fa01efb2e63ac62b791a8ddf0fee358f93be9f64b8f152648ad9d3/types_colorama-0.4.15.20240311-py3-none-any.whl", hash = "sha256:6391de60ddc0db3f147e31ecb230006a6823e81e380862ffca1e4695c13a0b8e", size = 5840, upload-time = "2024-03-11T02:15:50.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3904,11 +3904,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-setuptools"
|
||||
version = "80.9.0.20250822"
|
||||
version = "75.8.2.20250301"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/02/5f476d1a4f2bb23ba47c3aac6246cae1159d430937171e58860a9f1f47f8/types_setuptools-75.8.2.20250301.tar.gz", hash = "sha256:c900bceebfffc92a4abc3cfd4b3c39ead1a2298a73dae37e6bc09da7baf797a0", size = 48468, upload-time = "2025-03-01T02:51:55.248Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d8/aba63d60951fbec2917a9d2c8673605a57fdbfc9134268802988c99c7a4c/types_setuptools-75.8.2.20250301-py3-none-any.whl", hash = "sha256:3cc3e751db9e84eddf1e6d4f8c46bef2c77e6c25b0cd096f729ffa57d3d6a83a", size = 71841, upload-time = "2025-03-01T02:51:53.686Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4095,11 +4095,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "whitenoise"
|
||||
version = "6.11.0"
|
||||
version = "6.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/95/8c81ec6b6ebcbf8aca2de7603070ccf37dbb873b03f20708e0f7c1664bc6/whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f", size = 26432, upload-time = "2025-09-18T09:16:10.995Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/9a/4f4b84ff1f3a5c3cbc8070b6ecbbab6cd121c385244c9d24d80bb284190f/whitenoise-6.10.0.tar.gz", hash = "sha256:7b7e53de65d749cb1ce4a7100e751d9742e323b52746f9f93944c0d348ea2d02", size = 26412, upload-time = "2025-09-09T11:07:24.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/e9/4366332f9295fe0647d7d3251ce18f5615fbcb12d02c79a26f8dba9221b3/whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258", size = 20197, upload-time = "2025-09-18T09:16:09.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/3b/4fa26e02935334fa0eb1422c938b1db796c55de7a432cc86b9d8cf97260c/whitenoise-6.10.0-py3-none-any.whl", hash = "sha256:bad74a40b33b055ba59731b6048dd08d5647f273b72bef922aa43ddd287b02da", size = 20194, upload-time = "2025-09-09T11:07:23.544Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
Reference in New Issue
Block a user