mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-30 01:32:43 -05:00
Compare commits
25 Commits
feature-co
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
![]() |
01ff2e060f | ||
![]() |
86dbeb3a27 | ||
![]() |
e97217f267 | ||
![]() |
05d5d7e796 | ||
![]() |
e8957de4a7 | ||
![]() |
1717517e70 | ||
![]() |
af544177d4 | ||
![]() |
766af6a48a | ||
![]() |
e985051890 | ||
![]() |
764ad059d1 | ||
![]() |
5e47069934 | ||
![]() |
4ff09c4cf4 | ||
![]() |
53b393dab5 | ||
![]() |
6119c215e7 | ||
![]() |
8d1f23e9d6 | ||
![]() |
c8850fa752 | ||
![]() |
19a54b3b23 | ||
![]() |
1cdd8d9ba8 | ||
![]() |
4449dbadb5 | ||
![]() |
43b4f36026 | ||
![]() |
0e35acaef5 | ||
![]() |
19ff339804 | ||
![]() |
6b868a5ecb | ||
![]() |
3e4aa87cc5 | ||
![]() |
fc95d42b35 |
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@@ -17,11 +17,52 @@ env:
|
|||||||
DEFAULT_PYTHON_VERSION: "3.11"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
jobs:
|
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:
|
pre-commit:
|
||||||
# We want to run on external PRs, but not on our own internal PRs as they'll be run
|
needs:
|
||||||
# by the push to the branch. Without this if check, checks are duplicated since
|
- detect-duplicate
|
||||||
# internal PRs match both the push and pull_request events.
|
if: needs.detect-duplicate.outputs.should_run == 'true'
|
||||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
|
||||||
name: Linting Checks
|
name: Linting Checks
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
@@ -142,13 +183,11 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: junit.xml
|
files: junit.xml
|
||||||
- name: Upload backend coverage to Codecov
|
- name: Upload backend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
- name: Stop containers
|
- name: Stop containers
|
||||||
@@ -224,13 +263,11 @@ jobs:
|
|||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/
|
directory: src-ui/
|
||||||
- name: Upload frontend coverage to Codecov
|
- name: Upload frontend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
tests-frontend-e2e:
|
tests-frontend-e2e:
|
||||||
|
1
.github/workflows/repo-maintenance.yml
vendored
1
.github/workflows/repo-maintenance.yml
vendored
@@ -241,6 +241,7 @@ jobs:
|
|||||||
) {
|
) {
|
||||||
nodes {
|
nodes {
|
||||||
id,
|
id,
|
||||||
|
createdAt,
|
||||||
number,
|
number,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
upvoteCount,
|
upvoteCount,
|
||||||
|
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
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:
|
||||||
|
|
||||||
- Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together.
|
- As above, please start with a discussion! 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.
|
||||||
@@ -133,7 +135,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, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
- 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.
|
||||||
|
|
||||||
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.
|
||||||
|
@@ -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.17-python3.12-bookworm-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
@@ -1759,6 +1759,11 @@ 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
|
||||||
|
@@ -261,6 +261,10 @@ 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.
|
||||||
|
@@ -30,10 +30,10 @@ dependencies = [
|
|||||||
"django-cachalot~=2.8.0",
|
"django-cachalot~=2.8.0",
|
||||||
"django-celery-results~=2.6.0",
|
"django-celery-results~=2.6.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"django-cors-headers~=4.8.0",
|
"django-cors-headers~=4.9.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian~=3.1.2",
|
"django-guardian~=3.2.0",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.23.2",
|
"django-treenode>=0.23.2",
|
||||||
|
@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
|
|||||||
await expect(page.locator('pngx-document-list')).toHaveText(
|
await expect(page.locator('pngx-document-list')).toHaveText(
|
||||||
/Selected 61 of 61 documents/i
|
/Selected 61 of 61 documents/i
|
||||||
)
|
)
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await page.getByRole('button', { name: 'None' }).click()
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(1).click()
|
await page.locator('pngx-document-card-small').nth(1).click()
|
||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
await page.locator('pngx-document-card-small').nth(2).click()
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -166,13 +166,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-group mt-3 mb-1">
|
<div class="nav-group mt-3 mb-1">
|
||||||
<h6 class="sidebar-heading px-3 text-muted d-flex align-items-center">
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
<span i18n>Manage</span>
|
<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>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" #manageCollapse="ngbCollapse" [(ngbCollapse)]="isManageMenuCollapsed">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
@@ -246,124 +243,117 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-group mt-auto mb-1">
|
<div class="nav-group mt-auto mb-1">
|
||||||
<h6 class="sidebar-heading px-3 pt-4 text-muted d-flex align-items-center">
|
<h6 class="sidebar-heading px-3 pt-4 text-muted">
|
||||||
<span i18n>Administration</span>
|
<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>
|
</h6>
|
||||||
<div class="mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<ul class="nav flex-column" #adminCollapse="ngbCollapse" [(ngbCollapse)]="isAdminMenuCollapsed">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
tourAnchor="tour.settings">
|
||||||
tourAnchor="tour.settings">
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span>
|
||||||
<i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span>
|
</a>
|
||||||
</a>
|
</li>
|
||||||
</li>
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span>
|
||||||
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span>
|
</a>
|
||||||
</a>
|
</li>
|
||||||
</li>
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span>
|
||||||
<i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span>
|
</a>
|
||||||
</a>
|
</li>
|
||||||
</li>
|
<li class="nav-item app-link"
|
||||||
<li class="nav-item app-link"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
tourAnchor="tour.file-tasks">
|
||||||
tourAnchor="tour.file-tasks">
|
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||||
<i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
|
||||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
|
}</span>
|
||||||
}</span>
|
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
||||||
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
|
||||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
|
}
|
||||||
}
|
</a>
|
||||||
</a>
|
</li>
|
||||||
</li>
|
@if (permissionsService.isAdmin()) {
|
||||||
@if (permissionsService.isAdmin()) {
|
<li class="nav-item app-link">
|
||||||
<li class="nav-item app-link">
|
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
|
||||||
</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"
|
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
}
|
||||||
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<div class="me-3">
|
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span>
|
||||||
{{ versionString }}
|
</a>
|
||||||
</a>
|
</li>
|
||||||
</div>
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
|
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
||||||
<div class="version-check">
|
<div class="me-3">
|
||||||
<ng-template #updateAvailablePopContent>
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
|
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
</ng-template>
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<ng-template #updateCheckingNotEnabledPopContent>
|
{{ versionString }}
|
||||||
<p class="small mb-2">
|
</a>
|
||||||
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
|
</div>
|
||||||
</p>
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
|
||||||
<div class="btn-group btn-group-xs flex-fill w-100">
|
<div class="version-check">
|
||||||
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
|
<ng-template #updateAvailablePopContent>
|
||||||
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
|
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
|
||||||
</div>
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
<p class="small mb-0 mt-2">
|
</ng-template>
|
||||||
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
|
<ng-template #updateCheckingNotEnabledPopContent>
|
||||||
How does this work?
|
<p class="small mb-2">
|
||||||
</a>
|
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
|
||||||
</p>
|
</p>
|
||||||
</ng-template>
|
<div class="btn-group btn-group-xs flex-fill w-100">
|
||||||
@if (settingsService.updateCheckingIsSet) {
|
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
|
||||||
@if (appRemoteVersion.update_available) {
|
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
|
||||||
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
</div>
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
<p class="small mb-0 mt-2">
|
||||||
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
|
||||||
container="body">
|
How does this work?
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
</a>
|
||||||
@if (appRemoteVersion?.update_available) {
|
</p>
|
||||||
<ng-container i18n>Update available</ng-container>
|
</ng-template>
|
||||||
}
|
@if (settingsService.updateCheckingIsSet) {
|
||||||
</a>
|
@if (appRemoteVersion.update_available) {
|
||||||
}
|
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
} @else {
|
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
||||||
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
||||||
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
|
||||||
container="body">
|
container="body">
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
|
@if (appRemoteVersion?.update_available) {
|
||||||
|
<ng-container i18n>Update available</ng-container>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
} @else {
|
||||||
}
|
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
||||||
</div>
|
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||||
</li>
|
container="body">
|
||||||
</ul>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
</div>
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
@@ -89,8 +89,6 @@ export class AppFrameComponent
|
|||||||
appRemoteVersion: AppRemoteVersion
|
appRemoteVersion: AppRemoteVersion
|
||||||
|
|
||||||
isMenuCollapsed: boolean = true
|
isMenuCollapsed: boolean = true
|
||||||
isManageMenuCollapsed: boolean = false
|
|
||||||
isAdminMenuCollapsed: boolean = false
|
|
||||||
|
|
||||||
slimSidebarAnimating: boolean = false
|
slimSidebarAnimating: boolean = false
|
||||||
|
|
||||||
|
@@ -177,10 +177,16 @@ export class CustomFieldEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public removeSelectOption(index: number) {
|
public removeSelectOption(index: number) {
|
||||||
this.selectOptions.removeAt(index)
|
const globalIndex =
|
||||||
this._allSelectOptions.splice(
|
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
||||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
this._allSelectOptions.splice(globalIndex, 1)
|
||||||
1
|
|
||||||
|
const totalPages = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE)
|
||||||
)
|
)
|
||||||
|
const targetPage = Math.min(this.selectOptionsPage, totalPages)
|
||||||
|
|
||||||
|
this.selectOptionsPage = targetPage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,161 +1,144 @@
|
|||||||
<div class="d-flex flex-wrap gap-4">
|
<div class="d-flex flex-wrap gap-4">
|
||||||
<div class="d-flex align-items-center" role="group" aria-label="Select">
|
<div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
<label class="me-2" i18n>Edit:</label>
|
||||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container>
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
|
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createTag.bind(this)"
|
||||||
|
(opened)="openTagsDropdown()"
|
||||||
|
[(selectionModel)]="tagSelectionModel"
|
||||||
|
[documentCounts]="tagDocumentCounts"
|
||||||
|
(apply)="setTags($event)"
|
||||||
|
shortcutKey="t">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
|
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createCorrespondent.bind(this)"
|
||||||
|
(opened)="openCorrespondentDropdown()"
|
||||||
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
|
[documentCounts]="correspondentDocumentCounts"
|
||||||
|
(apply)="setCorrespondents($event)"
|
||||||
|
shortcutKey="y">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||||
|
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createDocumentType.bind(this)"
|
||||||
|
(opened)="openDocumentTypeDropdown()"
|
||||||
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
|
[documentCounts]="documentTypeDocumentCounts"
|
||||||
|
(apply)="setDocumentTypes($event)"
|
||||||
|
shortcutKey="u">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||||
|
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll || disabled"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createStoragePath.bind(this)"
|
||||||
|
(opened)="openStoragePathDropdown()"
|
||||||
|
[(selectionModel)]="storagePathsSelectionModel"
|
||||||
|
[documentCounts]="storagePathDocumentCounts"
|
||||||
|
(apply)="setStoragePaths($event)"
|
||||||
|
shortcutKey="i">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
||||||
|
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
||||||
|
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
||||||
|
[disabled]="!userCanEditAll"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createCustomField.bind(this)"
|
||||||
|
(opened)="openCustomFieldsDropdown()"
|
||||||
|
[(selectionModel)]="customFieldsSelectionModel"
|
||||||
|
[documentCounts]="customFieldDocumentCounts"
|
||||||
|
extraButtonTitle="Set values"
|
||||||
|
i18n-extraButtonTitle
|
||||||
|
(extraButton)="setCustomFieldValues($event)"
|
||||||
|
(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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
|
</div>
|
||||||
<label class="me-2" i18n>Select:</label>
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
<div class="btn-group">
|
<div class="btn-toolbar">
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
<div ngbDropdown>
|
||||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
||||||
|
<i-bs name="three-dots"></i-bs>
|
||||||
|
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
|
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
|
||||||
|
<i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container>
|
||||||
|
</button>
|
||||||
|
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
||||||
|
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||||
|
</button>
|
||||||
|
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
||||||
|
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||||
</button>
|
</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>
|
||||||
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
</div>
|
||||||
<label class="me-2" i18n>Edit:</label>
|
</div>
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
<div class="btn-group btn-group-sm">
|
||||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
@if (!awaitingDownload) {
|
||||||
[disabled]="!userCanEditAll || disabled"
|
<i-bs name="arrow-down"></i-bs>
|
||||||
[editing]="true"
|
}
|
||||||
[applyOnClose]="applyOnClose"
|
@if (awaitingDownload) {
|
||||||
[createRef]="createTag.bind(this)"
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
(opened)="openTagsDropdown()"
|
<span class="visually-hidden">Preparing download...</span>
|
||||||
[(selectionModel)]="tagSelectionModel"
|
</div>
|
||||||
[documentCounts]="tagDocumentCounts"
|
}
|
||||||
(apply)="setTags($event)"
|
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
||||||
shortcutKey="t">
|
</button>
|
||||||
</pngx-filterable-dropdown>
|
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
||||||
}
|
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
<form [formGroup]="downloadForm" class="px-3 py-1">
|
||||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
<p class="mb-1" i18n>Include:</p>
|
||||||
[disabled]="!userCanEditAll || disabled"
|
<div class="form-group ps-3 mb-2">
|
||||||
[editing]="true"
|
<div class="form-check">
|
||||||
[applyOnClose]="applyOnClose"
|
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
||||||
[createRef]="createCorrespondent.bind(this)"
|
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
||||||
(opened)="openCorrespondentDropdown()"
|
</div>
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
<div class="form-check">
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
||||||
(apply)="setCorrespondents($event)"
|
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
||||||
shortcutKey="y">
|
</div>
|
||||||
</pngx-filterable-dropdown>
|
</div>
|
||||||
}
|
<div class="form-check">
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
||||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
||||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
</div>
|
||||||
[disabled]="!userCanEditAll || disabled"
|
</form>
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createDocumentType.bind(this)"
|
|
||||||
(opened)="openDocumentTypeDropdown()"
|
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
|
||||||
(apply)="setDocumentTypes($event)"
|
|
||||||
shortcutKey="u">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
|
||||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
|
||||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll || disabled"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createStoragePath.bind(this)"
|
|
||||||
(opened)="openStoragePathDropdown()"
|
|
||||||
[(selectionModel)]="storagePathsSelectionModel"
|
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
|
||||||
(apply)="setStoragePaths($event)"
|
|
||||||
shortcutKey="i">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
|
||||||
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
|
||||||
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
|
||||||
[disabled]="!userCanEditAll"
|
|
||||||
[editing]="true"
|
|
||||||
[applyOnClose]="applyOnClose"
|
|
||||||
[createRef]="createCustomField.bind(this)"
|
|
||||||
(opened)="openCustomFieldsDropdown()"
|
|
||||||
[(selectionModel)]="customFieldsSelectionModel"
|
|
||||||
[documentCounts]="customFieldDocumentCounts"
|
|
||||||
extraButtonTitle="Set values"
|
|
||||||
i18n-extraButtonTitle
|
|
||||||
(extraButton)="setCustomFieldValues($event)"
|
|
||||||
(apply)="setCustomFields($event)">
|
|
||||||
</pngx-filterable-dropdown>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
</div>
|
||||||
<div class="btn-toolbar">
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
<div class="btn-group btn-group-sm">
|
||||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
||||||
</button>
|
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
<div ngbDropdown>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
</div>
|
||||||
<i-bs name="three-dots"></i-bs>
|
</div>
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
|
||||||
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
|
|
||||||
<i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container>
|
|
||||||
</button>
|
|
||||||
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
|
||||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
|
||||||
</button>
|
|
||||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
|
||||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
|
||||||
@if (!awaitingDownload) {
|
|
||||||
<i-bs name="arrow-down"></i-bs>
|
|
||||||
}
|
|
||||||
@if (awaitingDownload) {
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status">
|
|
||||||
<span class="visually-hidden">Preparing download...</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
|
||||||
<form [formGroup]="downloadForm" class="px-3 py-1">
|
|
||||||
<p class="mb-1" i18n>Include:</p>
|
|
||||||
<div class="form-group ps-3 mb-2">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
|
||||||
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
|
||||||
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
|
||||||
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
|
||||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
@@ -5,3 +5,7 @@
|
|||||||
.dropdown-menu{
|
.dropdown-menu{
|
||||||
--bs-dropdown-min-width: 12rem;
|
--bs-dropdown-min-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-group .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@@ -1,16 +1,36 @@
|
|||||||
<pngx-page-header [title]="getTitle()">
|
<pngx-page-header [title]="getTitle()">
|
||||||
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
<div ngbDropdown class="btn-group flex-fill">
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
|
||||||
<i-bs name="text-indent-left"></i-bs>
|
<i-bs name="text-indent-left"></i-bs>
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
<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>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||||
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
|
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
|
||||||
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
|
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
|
||||||
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
|
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div ngbDropdown class="btn-group flex-fill">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
||||||
<i-bs name="card-heading"></i-bs>
|
<i-bs name="card-heading"></i-bs>
|
||||||
@@ -126,8 +146,13 @@
|
|||||||
@if (!list.isReloading && isFiltered) {
|
@if (!list.isReloading && isFiltered) {
|
||||||
<button class="btn btn-link py-0" (click)="resetFilters()">
|
<button class="btn btn-link py-0" (click)="resetFilters()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
@if (list.collectionSize) {
|
@if (list.collectionSize) {
|
||||||
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
||||||
|
@@ -56,6 +56,7 @@ import {
|
|||||||
filterRulesDiffer,
|
filterRulesDiffer,
|
||||||
isFullTextFilterRule,
|
isFullTextFilterRule,
|
||||||
} from 'src/app/utils/filter-rules'
|
} 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 { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component'
|
||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||||
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
||||||
@@ -72,6 +73,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
|
|||||||
templateUrl: './document-list.component.html',
|
templateUrl: './document-list.component.html',
|
||||||
styleUrls: ['./document-list.component.scss'],
|
styleUrls: ['./document-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
ClearableBadgeComponent,
|
||||||
CustomFieldDisplayComponent,
|
CustomFieldDisplayComponent,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
BulkEditorComponent,
|
BulkEditorComponent,
|
||||||
|
@@ -109,10 +109,11 @@
|
|||||||
<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 d-none d-sm-block" i18n>Sort Order</div>
|
<div class="col-1 d-none d-sm-block" i18n>Sort Order</div>
|
||||||
<div class="col" i18n>Account</div>
|
<div class="col-2" i18n>Account</div>
|
||||||
<div class="col d-none d-sm-block" i18n>Status</div>
|
<div class="col-2 d-none d-sm-block" i18n>Status</div>
|
||||||
<div class="col" i18n>Actions</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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -127,9 +128,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 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</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">{{(mailAccountService.getCached(rule.account) | async)?.name}}</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 d-none d-sm-flex">
|
<div class="col-2 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'">
|
||||||
@@ -137,7 +138,12 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<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="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>
|
||||||
|
@@ -409,4 +409,13 @@ 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])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -27,6 +27,7 @@ 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',
|
||||||
@@ -347,6 +348,14 @@ 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,
|
||||||
|
@@ -0,0 +1,107 @@
|
|||||||
|
<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>
|
@@ -0,0 +1,8 @@
|
|||||||
|
::ng-deep .popover {
|
||||||
|
max-width: 350px;
|
||||||
|
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,150 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,96 @@
|
|||||||
|
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,4 +71,20 @@ 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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -62,6 +62,8 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filterData(data: Tag[]) {
|
filterData(data: Tag[]) {
|
||||||
return data.filter((tag) => !tag.parent)
|
return this.nameFilter?.length
|
||||||
|
? [...data]
|
||||||
|
: data.filter((tag) => !tag.parent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
12
src-ui/src/app/data/processed-mail.ts
Normal file
12
src-ui/src/app/data/processed-mail.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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,6 +28,7 @@ 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({
|
||||||
|
39
src-ui/src/app/services/rest/processed-mail.service.spec.ts
Normal file
39
src-ui/src/app/services/rest/processed-mail.service.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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({})
|
||||||
|
})
|
||||||
|
})
|
19
src-ui/src/app/services/rest/processed-mail.service.ts
Normal file
19
src-ui/src/app/services/rest/processed-mail.service.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
checkCircle,
|
||||||
checkCircleFill,
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
chevronDoubleRight,
|
chevronDoubleRight,
|
||||||
chevronDown,
|
|
||||||
chevronRight,
|
chevronRight,
|
||||||
chevronUp,
|
|
||||||
clipboard,
|
clipboard,
|
||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
|
clockHistory,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
@@ -265,17 +265,17 @@ const icons = {
|
|||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
checkCircle,
|
||||||
checkCircleFill,
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
chevronDoubleRight,
|
chevronDoubleRight,
|
||||||
chevronDown,
|
|
||||||
chevronRight,
|
chevronRight,
|
||||||
chevronUp,
|
|
||||||
clipboard,
|
clipboard,
|
||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
|
clockHistory,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
|
@@ -164,6 +164,9 @@ class BarcodePlugin(ConsumeTaskPlugin):
|
|||||||
mailrule_id=self.input_doc.mailrule_id,
|
mailrule_id=self.input_doc.mailrule_id,
|
||||||
# Can't use same folder or the consume might grab it again
|
# Can't use same folder or the consume might grab it again
|
||||||
original_file=(tmp_dir / new_document.name).resolve(),
|
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
|
# All the same metadata
|
||||||
self.metadata,
|
self.metadata,
|
||||||
|
@@ -156,6 +156,7 @@ class ConsumableDocument:
|
|||||||
|
|
||||||
source: DocumentSource
|
source: DocumentSource
|
||||||
original_file: Path
|
original_file: Path
|
||||||
|
original_path: Path | 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)
|
||||||
|
|
||||||
|
@@ -82,6 +82,13 @@ 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
|
||||||
|
|
||||||
@@ -323,7 +330,12 @@ 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
|
||||||
file_still_exists = filepath.exists() and filepath.is_file()
|
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:
|
if waited_long_enough and file_still_exists:
|
||||||
_consume(filepath)
|
_consume(filepath)
|
||||||
|
@@ -92,6 +92,9 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
|
|||||||
# doc to doc is obviously not useful
|
# doc to doc is obviously not useful
|
||||||
if first_doc.pk == second_doc.pk:
|
if first_doc.pk == second_doc.pk:
|
||||||
continue
|
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
|
# 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 is the same as doc 2 to doc 1
|
||||||
doc_1_to_doc_2 = (first_doc.pk, second_doc.pk)
|
doc_1_to_doc_2 = (first_doc.pk, second_doc.pk)
|
||||||
|
@@ -314,11 +314,19 @@ def consumable_document_matches_workflow(
|
|||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
|
|
||||||
# Document path vs trigger path
|
# 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 (
|
if (
|
||||||
trigger.filter_path is not None
|
trigger.filter_path is not None
|
||||||
and len(trigger.filter_path) > 0
|
and len(trigger.filter_path) > 0
|
||||||
and not fnmatch(
|
and not fnmatch(
|
||||||
document.original_file,
|
match_against,
|
||||||
trigger.filter_path,
|
trigger.filter_path,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
|
@@ -614,14 +614,16 @@ class TestBarcodeNewConsume(
|
|||||||
self.assertIsNotFile(temp_copy)
|
self.assertIsNotFile(temp_copy)
|
||||||
|
|
||||||
# Check the split files exist
|
# Check the split files exist
|
||||||
|
# Check the original_path is set
|
||||||
# Check the source is unchanged
|
# Check the source is unchanged
|
||||||
# Check the overrides are unchanged
|
# Check the overrides are unchanged
|
||||||
for (
|
for (
|
||||||
new_input_doc,
|
new_input_doc,
|
||||||
new_doc_overrides,
|
new_doc_overrides,
|
||||||
) in self.get_all_consume_delay_call_args():
|
) in self.get_all_consume_delay_call_args():
|
||||||
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
|
|
||||||
self.assertIsFile(new_input_doc.original_file)
|
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.assertEqual(overrides, new_doc_overrides)
|
self.assertEqual(overrides, new_doc_overrides)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -209,6 +209,26 @@ 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")
|
||||||
|
@@ -206,3 +206,29 @@ class TestFuzzyMatchCommand(TestCase):
|
|||||||
self.assertEqual(Document.objects.count(), 2)
|
self.assertEqual(Document.objects.count(), 2)
|
||||||
self.assertIsNotNone(Document.objects.get(pk=1))
|
self.assertIsNotNone(Document.objects.get(pk=1))
|
||||||
self.assertIsNotNone(Document.objects.get(pk=2))
|
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 ""
|
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-17 22:44+0000\n"
|
"POT-Creation-Date: 2025-09-22 18:20+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:368
|
#: paperless/urls.py:370
|
||||||
msgid "Paperless-ngx administration"
|
msgid "Paperless-ngx administration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -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 = DATA_DIR / "celerybeat-schedule.db"
|
CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db")
|
||||||
|
|
||||||
|
|
||||||
# Cachalot: Database read cache.
|
# Cachalot: Database read cache.
|
||||||
|
@@ -57,6 +57,7 @@ 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)
|
||||||
@@ -77,6 +78,7 @@ 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 = [
|
||||||
|
12
src/paperless_mail/filters.py
Normal file
12
src/paperless_mail/filters.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django_filters import FilterSet
|
||||||
|
|
||||||
|
from paperless_mail.models import ProcessedMail
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedMailFilterSet(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = ProcessedMail
|
||||||
|
fields = {
|
||||||
|
"rule": ["exact"],
|
||||||
|
"status": ["exact"],
|
||||||
|
}
|
@@ -6,6 +6,7 @@ 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):
|
||||||
@@ -130,3 +131,20 @@ 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",
|
||||||
|
]
|
||||||
|
@@ -3,6 +3,7 @@ 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
|
||||||
@@ -13,6 +14,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -721,3 +723,285 @@ 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)
|
||||||
|
@@ -3,8 +3,10 @@ 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
|
||||||
@@ -12,23 +14,29 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +134,34 @@ 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
|
||||||
|
|
||||||
|
53
uv.lock
generated
53
uv.lock
generated
@@ -730,15 +730,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-cors-headers"
|
name = "django-cors-headers"
|
||||||
version = "4.8.0"
|
version = "4.9.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "django", 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/89/8e/6225441edcfe179bf4861e9e67489e33375e0b66316c8d7b9edaae863d37/django_cors_headers-4.8.0.tar.gz", hash = "sha256:0a12a2efcd59a3cea741e44db8ab589e929949de5bc4cdf35a29c6ae77297686", size = 21425, upload-time = "2025-09-08T15:58:05.34Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -782,15 +782,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-guardian"
|
name = "django-guardian"
|
||||||
version = "3.1.3"
|
version = "3.2.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ 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')" },
|
{ 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/81/d3/436a44c7688fce1a978224c349ba66c95bf9103d548596b7a2694fd58c03/django_guardian-3.1.3.tar.gz", hash = "sha256:12b5e66c18c97088b0adfa033ab14be68c321c170fd3ec438898271f00a71699", size = 93571, upload-time = "2025-09-10T08:36:23.928Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -807,14 +807,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-soft-delete"
|
name = "django-soft-delete"
|
||||||
version = "1.0.19"
|
version = "1.0.21"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django", 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/ce/77/44a6615a7da3ca0ddc624039d399d17d6c3503e1c2dad08b443f8d4a3570/django_soft_delete-1.0.19.tar.gz", hash = "sha256:c67ee8920e1456eca84cc59b3304ef27fa9d476b516be726ce7e1fc558502908", size = 11993, upload-time = "2025-06-19T20:32:20.373Z" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2182,10 +2182,10 @@ requires-dist = [
|
|||||||
{ name = "django-cachalot", specifier = "~=2.8.0" },
|
{ name = "django-cachalot", specifier = "~=2.8.0" },
|
||||||
{ name = "django-celery-results", specifier = "~=2.6.0" },
|
{ name = "django-celery-results", specifier = "~=2.6.0" },
|
||||||
{ name = "django-compression-middleware", specifier = "~=0.5.0" },
|
{ name = "django-compression-middleware", specifier = "~=0.5.0" },
|
||||||
{ name = "django-cors-headers", specifier = "~=4.8.0" },
|
{ name = "django-cors-headers", specifier = "~=4.9.0" },
|
||||||
{ name = "django-extensions", specifier = "~=4.1" },
|
{ name = "django-extensions", specifier = "~=4.1" },
|
||||||
{ name = "django-filter", specifier = "~=25.1" },
|
{ name = "django-filter", specifier = "~=25.1" },
|
||||||
{ name = "django-guardian", specifier = "~=3.1.2" },
|
{ name = "django-guardian", specifier = "~=3.2.0" },
|
||||||
{ name = "django-multiselectfield", specifier = "~=1.0.1" },
|
{ name = "django-multiselectfield", specifier = "~=1.0.1" },
|
||||||
{ name = "django-soft-delete", specifier = "~=1.0.18" },
|
{ name = "django-soft-delete", specifier = "~=1.0.18" },
|
||||||
{ name = "django-treenode", specifier = ">=0.23.2" },
|
{ name = "django-treenode", specifier = ">=0.23.2" },
|
||||||
@@ -2794,14 +2794,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-mock"
|
name = "pytest-mock"
|
||||||
version = "3.14.1"
|
version = "3.15.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pytest", 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/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2819,16 +2819,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-sugar"
|
name = "pytest-sugar"
|
||||||
version = "1.0.0"
|
version = "1.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
|
||||||
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "termcolor", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "termcolor", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", size = 14992, upload-time = "2024-02-01T18:30:36.735Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171, upload-time = "2024-02-01T18:30:29.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3790,11 +3789,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-dateparser"
|
name = "types-dateparser"
|
||||||
version = "1.2.0.20250208"
|
version = "1.2.2.20250809"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/da/9e/06305e20da13e1014398dac11c346089294f933a1e9e18c80b752b97a795/types_dateparser-1.2.0.20250208.tar.gz", hash = "sha256:11ad024b43a655bcab564f21b172a117e6a09d0fc2e8a8131f52e5b68a59a2a6", size = 15078, upload-time = "2025-02-08T02:37:31.738Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/63/54/2d2b77d1beba5bdb7faeabc7d7f0b9b2f8e428f79f45a144ad7ab87d1a29/types_dateparser-1.2.2.20250809.tar.gz", hash = "sha256:a898f5527e6c34d213bc5d85254b8246d8b1e76239ed9243711198add0c8a29c", size = 15804, upload-time = "2025-08-09T03:15:11.298Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/c3/7bbc3d907eaa5cf604207f19b60be5773e12065c621e1bb61f7f9f5159ec/types_dateparser-1.2.0.20250208-py3-none-any.whl", hash = "sha256:bfe9d2f36fca22900797bfdd240e3175d885b9541237447ddd7161ea1be2ff77", size = 21033, upload-time = "2025-02-08T02:37:30.335Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/5a/a5cf930804f639f5f1c58434613a1bbc1bd4641e29aec07444f316b41dff/types_dateparser-1.2.2.20250809-py3-none-any.whl", hash = "sha256:f12ae46abc3085e60e16fbe55730c5acbce980cbe3b176b17b08b4cef85850ef", size = 22140, upload-time = "2025-08-09T03:15:10.234Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3817,11 +3816,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-markdown"
|
name = "types-markdown"
|
||||||
version = "3.7.0.20241204"
|
version = "3.9.0.20250906"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/3c/874ac6ce93f4e6bd0283a5df2c8065f4e623c6c3bc0b2fb98c098313cb73/types_markdown-3.7.0.20241204.tar.gz", hash = "sha256:ecca2b25cd23163fd28ed5ba34d183d731da03e8a5ed3a20b60daded304c5410", size = 17820, upload-time = "2024-12-04T02:57:29.117Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fb/dd/8a91a5f4dce705eff6862e01e8fd9984cdc55e6d6c3cfcadc68410c43abf/types_markdown-3.9.0.20250906.tar.gz", hash = "sha256:f02dc1a2d130b093de4910c64b2d0a811ae7020f03624df41c667818d2fee050", size = 19439, upload-time = "2025-09-06T02:45:23.617Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/26/3c9730e845cfd0d587e0dfa9c1975f02f9f49407afbf30800094bdac0286/types_Markdown-3.7.0.20241204-py3-none-any.whl", hash = "sha256:f96146c367ea9c82bfe9903559d72706555cc2a1a3474c58ebba03b418ab18da", size = 23572, upload-time = "2024-12-04T02:57:28.217Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/17/b0b9d315422eae106af1d30e51afe4b59c18b42311605fed9068ac6aa065/types_markdown-3.9.0.20250906-py3-none-any.whl", hash = "sha256:afac4297e4e75f00b4043f9b3a989dc5924230d065996a233b9bce894c438cc2", size = 25830, upload-time = "2025-09-06T02:45:22.211Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4095,11 +4094,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whitenoise"
|
name = "whitenoise"
|
||||||
version = "6.10.0"
|
version = "6.11.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
Reference in New Issue
Block a user