Compare commits

..
43 changed files with 193 additions and 271 deletions

No files matched your search

@@ -10,11 +10,9 @@ if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then
else else
cd ${PAPERLESS_SRC_DIR} cd ${PAPERLESS_SRC_DIR}
# Django system checks already ran once in init-system-checks, skip repeating them
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec python3 manage.py document_consumer --skip-checks exec python3 manage.py document_consumer
else else
exec s6-setuidgid paperless python3 manage.py document_consumer --skip-checks exec s6-setuidgid paperless python3 manage.py document_consumer
fi fi
fi fi
@@ -3,9 +3,8 @@
cd ${PAPERLESS_SRC_DIR} cd ${PAPERLESS_SRC_DIR}
# Django system checks already ran once in init-system-checks, skip repeating them
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec /usr/local/bin/celery --skip-checks --app paperless beat --loglevel INFO exec /usr/local/bin/celery --app paperless beat --loglevel INFO
else else
exec s6-setuidgid paperless /usr/local/bin/celery --skip-checks --app paperless beat --loglevel INFO exec s6-setuidgid paperless /usr/local/bin/celery --app paperless beat --loglevel INFO
fi fi
@@ -3,9 +3,8 @@
cd ${PAPERLESS_SRC_DIR} cd ${PAPERLESS_SRC_DIR}
# Django system checks already ran once in init-system-checks, skip repeating them
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec /usr/local/bin/celery --skip-checks --app paperless worker --loglevel INFO --without-mingle --without-gossip exec /usr/local/bin/celery --app paperless worker --loglevel INFO --without-mingle --without-gossip
else else
exec s6-setuidgid paperless /usr/local/bin/celery --skip-checks --app paperless worker --loglevel INFO --without-mingle --without-gossip exec s6-setuidgid paperless /usr/local/bin/celery --app paperless worker --loglevel INFO --without-mingle --without-gossip
fi fi
+1 -9
View File
@@ -43,7 +43,6 @@ def configure_environment(instance_root: Path) -> None:
def seed_database() -> None: def seed_database() -> None:
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management import call_command from django.core.management import call_command
from django.utils import timezone from django.utils import timezone
@@ -64,18 +63,11 @@ def seed_database() -> None:
username="playwright", username="playwright",
password="playwright", # NOSONAR password="playwright", # NOSONAR
) )
viewer = User.objects.create_user( User.objects.create_user(
username="viewer", username="viewer",
password="viewer", # NOSONAR password="viewer", # NOSONAR
) )
viewer.user_permissions.add(
Permission.objects.get(
content_type__app_label="documents",
codename="view_uisettings",
),
)
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True, owner=admin) inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True, owner=admin)
quick_filter = Tag.objects.create(name="Another Sample Tag", owner=admin) quick_filter = Tag.objects.create(name="Another Sample Tag", owner=admin)
Tag.objects.create(name="TagWithPartial", owner=admin) Tag.objects.create(name="TagWithPartial", owner=admin)
@@ -80,21 +80,12 @@ test('should show a list of notes', async ({ page }) => {
test('should support quick filters', async ({ page }) => { test('should support quick filters', async ({ page }) => {
await page.goto('/documents/1/details') await page.goto('/documents/1/details')
// filter button is available before tags have loaded
const selectedTags = page.locator('pngx-input-tags .ng-value pngx-tag')
await expect(selectedTags).toHaveCount(2)
await expect(
selectedTags.getByText('Another Sample Tag', { exact: true })
).toBeVisible()
await expect(selectedTags.getByText('Inbox', { exact: true })).toBeVisible()
await page await page
.getByRole('button', { name: 'Filter documents with these Tags' }) .getByRole('button', { name: 'Filter documents with these Tags' })
.click() .click()
await expect(page).toHaveURL((url) => { await expect(page).toHaveURL(
const tags = url.searchParams.get('tags__id__all')?.split(',').sort() /tags__id__all=2,1&sort=created&reverse=1&page=1/
return url.pathname === '/documents' && tags?.join(',') === '1,2' )
})
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
}) })
test('should finish reloading the preview after a remote document update', async ({ test('should finish reloading the preview after a remote document update', async ({
@@ -6,15 +6,8 @@ test.use({
}, },
}) })
test.beforeEach(async ({ page }) => {
const settingsLoaded = page.waitForResponse((response) =>
response.url().endsWith('/api/ui_settings/')
)
await page.goto('/dashboard')
expect((await settingsLoaded).status()).toBe(200)
})
test('should not allow user to edit settings', async ({ page }) => { test('should not allow user to edit settings', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached() await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
await page.goto('/settings') await page.goto('/settings')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
@@ -23,6 +16,7 @@ test('should not allow user to edit settings', async ({ page }) => {
}) })
test('should not allow user to view documents', async ({ page }) => { test('should not allow user to view documents', async ({ page }) => {
await page.goto('/dashboard')
await expect( await expect(
page.locator('nav').getByRole('link', { name: 'Documents' }) page.locator('nav').getByRole('link', { name: 'Documents' })
).not.toBeAttached() ).not.toBeAttached()
@@ -37,6 +31,7 @@ test('should not allow user to view documents', async ({ page }) => {
}) })
test('should not allow user to view correspondents', async ({ page }) => { test('should not allow user to view correspondents', async ({ page }) => {
await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached() ).not.toBeAttached()
@@ -47,6 +42,7 @@ test('should not allow user to view correspondents', async ({ page }) => {
}) })
test('should not allow user to view tags', async ({ page }) => { test('should not allow user to view tags', async ({ page }) => {
await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached() ).not.toBeAttached()
@@ -57,6 +53,7 @@ test('should not allow user to view tags', async ({ page }) => {
}) })
test('should not allow user to view document types', async ({ page }) => { test('should not allow user to view document types', async ({ page }) => {
await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached() ).not.toBeAttached()
@@ -67,6 +64,7 @@ test('should not allow user to view document types', async ({ page }) => {
}) })
test('should not allow user to view storage paths', async ({ page }) => { test('should not allow user to view storage paths', async ({ page }) => {
await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached() ).not.toBeAttached()
@@ -77,6 +75,7 @@ test('should not allow user to view storage paths', async ({ page }) => {
}) })
test('should not allow user to view logs', async ({ page }) => { test('should not allow user to view logs', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached() await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
await page.goto('/logs') await page.goto('/logs')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
@@ -85,6 +84,7 @@ test('should not allow user to view logs', async ({ page }) => {
}) })
test('should not allow user to view tasks', async ({ page }) => { test('should not allow user to view tasks', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached() await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
await page.goto('/tasks') await page.goto('/tasks')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
+5 -5
View File
@@ -10334,18 +10334,18 @@
<context context-type="linenumber">294,295</context> <context context-type="linenumber">294,295</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3420321797707163677" datatype="html"> <trans-unit id="2179847500064178686" datatype="html">
<source>Preview document</source> <source>Edit document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">328</context> <context context-type="linenumber">328</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2179847500064178686" datatype="html"> <trans-unit id="3420321797707163677" datatype="html">
<source>Edit document</source> <source>Preview document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
<context context-type="linenumber">331</context> <context context-type="linenumber">329</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4512084577073831437" datatype="html"> <trans-unit id="4512084577073831437" datatype="html">
@@ -1,3 +1,10 @@
.custom-fields-dropdown { .custom-fields-dropdown {
min-width: 300px; min-width: 300px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -245px !important;
}
}
} }
@@ -308,7 +308,7 @@
</tr> </tr>
<tr class="align-middle"> <tr class="align-middle">
<td i18n>Original SHA256 checksum</td> <td i18n>Original SHA256 checksum</td>
<td><span class="badge bg-light text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td> <td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
</tr> </tr>
<tr> <tr>
<td i18n>Original file size</td> <td i18n>Original file size</td>
@@ -465,7 +465,7 @@
@switch (archiveContentRenderType) { @switch (archiveContentRenderType) {
@case (ContentRenderType.PDF) { @case (ContentRenderType.PDF) {
@if (!useNativePdfViewer) { @if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container" tabindex="-1"> <div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer <pngx-pdf-viewer
[src]="pdfSource()" [src]="pdfSource()"
[sourceRevision]="previewRevision()" [sourceRevision]="previewRevision()"
@@ -477,7 +477,7 @@
(loadError)="onError($event)" (loadError)="onError($event)"
(afterLoadComplete)="pdfPreviewLoaded($event)"> (afterLoadComplete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer> </pngx-pdf-viewer>
<div class="preview-controls position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow"> <div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
@if (previewNumPages()) { @if (previewNumPages()) {
<div class="input-group input-group-sm w-auto flex-nowrap"> <div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label /> <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
@@ -12,22 +12,6 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
&:focus {
outline: none;
}
.preview-controls {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
}
&:hover .preview-controls,
&:focus-within .preview-controls {
opacity: 1;
pointer-events: auto;
}
} }
.btn-group .dropdown-toggle-split { .btn-group .dropdown-toggle-split {
@@ -72,7 +72,7 @@
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()"> <a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
<i-bs name="diagram-3" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>More like this</span> <i-bs name="diagram-3" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>More like this</span>
</a> </a>
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary"> <a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<i-bs name="file-earmark-richtext" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>Open</span> <i-bs name="file-earmark-richtext" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>Open</span>
</a> </a>
<pngx-preview-popup [document]="document()" #popupPreview> <pngx-preview-popup [document]="document()" #popupPreview>
@@ -10,6 +10,7 @@ import {
NgbTooltipModule, NgbTooltipModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
@@ -51,6 +52,7 @@ describe('DocumentCardLargeComponent', () => {
DocumentCardLargeComponent, DocumentCardLargeComponent,
DocumentTitlePipe, DocumentTitlePipe,
CustomDatePipe, CustomDatePipe,
IfPermissionsDirective,
SafeUrlPipe, SafeUrlPipe,
IsNumberPipe, IsNumberPipe,
PreviewPopupComponent, PreviewPopupComponent,
@@ -20,6 +20,7 @@ import {
Document, Document,
} from 'src/app/data/document' } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe' import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -50,6 +51,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
CorrespondentNamePipe, CorrespondentNamePipe,
DocumentTypeNamePipe, DocumentTypeNamePipe,
StoragePathNamePipe, StoragePathNamePipe,
IfPermissionsDirective,
CustomDatePipe, CustomDatePipe,
RouterModule, RouterModule,
NgbTooltipModule, NgbTooltipModule,
@@ -144,7 +144,7 @@
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100"> <div class="btn-group w-100">
@if (document()) { @if (document()) {
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title> <a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
<i-bs name="file-earmark-richtext"></i-bs> <i-bs name="file-earmark-richtext"></i-bs>
</a> </a>
<pngx-preview-popup [document]="document()" #popupPreview> <pngx-preview-popup [document]="document()" #popupPreview>
@@ -20,6 +20,7 @@ import {
Document, Document,
} from 'src/app/data/document' } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe' import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -50,6 +51,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
CorrespondentNamePipe, CorrespondentNamePipe,
DocumentTypeNamePipe, DocumentTypeNamePipe,
StoragePathNamePipe, StoragePathNamePipe,
IfPermissionsDirective,
CustomDatePipe, CustomDatePipe,
RouterModule, RouterModule,
NgbTooltipModule, NgbTooltipModule,
@@ -208,7 +208,7 @@
i18n>Correspondent</th> i18n>Correspondent</th>
} }
@if (activeDisplayFields.includes(DisplayField.TITLE)) { @if (activeDisplayFields.includes(DisplayField.TITLE)) {
<th class="cursor-pointer ps-5" <th class="cursor-pointer"
pngxSortable="title" pngxSortable="title"
title="Sort by title" i18n-title title="Sort by title" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
@@ -322,13 +322,13 @@
</td> </td>
} }
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) { @if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
<td width="30%" class="position-relative" [class.ps-5]="activeDisplayFields.includes(DisplayField.TITLE)"> <td width="30%">
@if (activeDisplayFields.includes(DisplayField.TITLE)) { @if (activeDisplayFields.includes(DisplayField.TITLE)) {
<div class="d-inline-block" (mouseleave)="popupPreview.close()"> <div class="d-inline-block" (mouseleave)="popupPreview.close()">
<pngx-preview-popup class="position-absolute start-0" [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview> <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
<i-bs name="eye"></i-bs> <i-bs name="eye"></i-bs>
</pngx-preview-popup> </pngx-preview-popup>
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
</div> </div>
} }
@if (activeDisplayFields.includes(DisplayField.TAGS)) { @if (activeDisplayFields.includes(DisplayField.TAGS)) {
@@ -87,10 +87,6 @@ $paperless-card-breakpoints: (
} }
} }
.table .ps-5 {
padding-left: 2.2rem !important;
}
a { a {
cursor: pointer; cursor: pointer;
} }
+3 -7
View File
@@ -10,6 +10,9 @@ from typing import TYPE_CHECKING
import regex as regex_mod import regex as regex_mod
from django.conf import settings from django.conf import settings
from pdf2image import convert_from_path from pdf2image import convert_from_path
from pikepdf import Page
from pikepdf import PasswordError
from pikepdf import Pdf
from documents.converters import convert_from_tiff_to_pdf from documents.converters import convert_from_tiff_to_pdf
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
@@ -31,7 +34,6 @@ from documents.utils import maybe_override_pixel_limit
from paperless.config import BarcodeConfig from paperless.config import BarcodeConfig
if TYPE_CHECKING: if TYPE_CHECKING:
from pikepdf import Page
from PIL import Image from PIL import Image
logger = logging.getLogger("paperless.barcodes") logger = logging.getLogger("paperless.barcodes")
@@ -280,8 +282,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
# No op if not a TIFF # No op if not a TIFF
self.convert_from_tiff_to_pdf() self.convert_from_tiff_to_pdf()
from pikepdf import PasswordError
try: try:
self.barcodes = scan_pdf( self.barcodes = scan_pdf(
self.pdf_file, self.pdf_file,
@@ -433,8 +433,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
document_paths = [] document_paths = []
fname: str = self.input_doc.original_file.stem fname: str = self.input_doc.original_file.stem
from pikepdf import Pdf
with Pdf.open(self.pdf_file) as input_pdf: with Pdf.open(self.pdf_file) as input_pdf:
# Start with an empty document # Start with an empty document
current_document: list[Page] = [] current_document: list[Page] = []
@@ -518,8 +516,6 @@ def scan_pdf(pdf_path: Path, settings: BarcodeConfig, work_dir: Path) -> list[Ba
""" """
barcodes: list[Barcode] = [] barcodes: list[Barcode] = []
from pikepdf import Pdf
with Pdf.open(pdf_path) as pdf: with Pdf.open(pdf_path) as pdf:
num_of_pages = len(pdf.pages) num_of_pages = len(pdf.pages)
logger.debug(f"PDF has {num_of_pages} pages") logger.debug(f"PDF has {num_of_pages} pages")
+1 -2
View File
@@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
import img2pdf
from PIL import Image from PIL import Image
from documents.utils import copy_basic_file_stats from documents.utils import copy_basic_file_stats
@@ -16,8 +17,6 @@ def convert_from_tiff_to_pdf(tiff_path: Path, target_directory: Path) -> Path:
Returns the path of the PDF created. Returns the path of the PDF created.
""" """
import img2pdf
# override pixel setting if needed # override pixel setting if needed
maybe_override_pixel_limit() maybe_override_pixel_limit()
+1 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Final from typing import Final
from django.conf import settings from django.conf import settings
from pikepdf import Pdf
from documents.consumer import ConsumerError from documents.consumer import ConsumerError
from documents.converters import convert_from_tiff_to_pdf from documents.converters import convert_from_tiff_to_pdf
@@ -84,8 +85,6 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
valid_staging_exists = True valid_staging_exists = True
if valid_staging_exists: if valid_staging_exists:
from pikepdf import Pdf
try: try:
# Collate pages from second PDF in reverse order # Collate pages from second PDF in reverse order
with Pdf.open(staging) as pdf1, Pdf.open(pdf_file) as pdf2: with Pdf.open(staging) as pdf1, Pdf.open(pdf_file) as pdf2:
+1 -2
View File
@@ -11,6 +11,7 @@ from decimal import InvalidOperation
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
import dateparser
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Case from django.db.models import Case
@@ -349,8 +350,6 @@ class DateQueryField(serializers.DateField):
if not isinstance(value, str): if not isinstance(value, str):
raise raise
import dateparser
try: try:
parsed = dateparser.parse( parsed = dateparser.parse(
value, value,
+2 -14
View File
@@ -14,6 +14,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import NamedTuple from typing import NamedTuple
import pikepdf
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from collections.abc import Iterable from collections.abc import Iterable
@@ -41,8 +43,6 @@ def rotate_pdf(src: Path, dst: Path, degrees: int) -> None:
Rotate every page relatively on the opened document, not a rebuild, so Info, Rotate every page relatively on the opened document, not a rebuild, so Info,
XMP and outlines are kept. ``src`` is not modified. XMP and outlines are kept. ``src`` is not modified.
""" """
import pikepdf
with pikepdf.open(src) as pdf: with pikepdf.open(src) as pdf:
for page in pdf.pages: for page in pdf.pages:
page.rotate(degrees, relative=True) page.rotate(degrees, relative=True)
@@ -58,8 +58,6 @@ def remove_pages(src: Path, dst: Path, pages: Iterable[int]) -> None:
Duplicates are ignored. Pages are removed highest first so earlier removals Duplicates are ignored. Pages are removed highest first so earlier removals
never shift the index of later ones. never shift the index of later ones.
""" """
import pikepdf
unique = sorted(set(pages)) unique = sorted(set(pages))
_require_positive(unique) _require_positive(unique)
with pikepdf.open(src) as pdf: with pikepdf.open(src) as pdf:
@@ -83,8 +81,6 @@ def build_pdfs(
(Info, XMP, outlines) is not carried over. Returns the written paths in output (Info, XMP, outlines) is not carried over. Returns the written paths in output
order. order.
""" """
import pikepdf
for specs, _ in outputs: for specs, _ in outputs:
_require_positive(spec.page for spec in specs) _require_positive(spec.page for spec in specs)
@@ -144,8 +140,6 @@ def needs_decrypt(src: Path) -> bool:
True if ``src`` is encrypted. A PDF that needs a password to open at all True if ``src`` is encrypted. A PDF that needs a password to open at all
counts as encrypted. counts as encrypted.
""" """
import pikepdf
try: try:
with pikepdf.open(src) as pdf: with pikepdf.open(src) as pdf:
return bool(pdf.is_encrypted) return bool(pdf.is_encrypted)
@@ -160,8 +154,6 @@ def decrypt_pdf(src: Path, make_dst: Callable[[], Path], password: str) -> Path:
``make_dst`` is only called once the password has been accepted, so a wrong ``make_dst`` is only called once the password has been accepted, so a wrong
password never leaves a destination behind. password never leaves a destination behind.
""" """
import pikepdf
with pikepdf.open(src, password=password) as pdf: with pikepdf.open(src, password=password) as pdf:
pdf.remove_unreferenced_resources() pdf.remove_unreferenced_resources()
dst = make_dst() dst = make_dst()
@@ -178,8 +170,6 @@ class PdfMerger:
""" """
def __init__(self) -> None: def __init__(self) -> None:
import pikepdf
self._pdf = pikepdf.new() self._pdf = pikepdf.new()
self._version: str = self._pdf.pdf_version self._version: str = self._pdf.pdf_version
@@ -195,8 +185,6 @@ class PdfMerger:
self._pdf.close() self._pdf.close()
def add(self, path: Path) -> None: def add(self, path: Path) -> None:
import pikepdf
with pikepdf.open(str(path)) as pdf: with pikepdf.open(str(path)) as pdf:
self._version = max(self._version, pdf.pdf_version) self._version = max(self._version, pdf.pdf_version)
self._pdf.pages.extend(pdf.pages) self._pdf.pages.extend(pdf.pages)
+3 -6
View File
@@ -576,12 +576,9 @@ def annotate_document_count_by_ids(
or CustomFieldInstance) or CustomFieldInstance)
related_object_field: field on the relation pointing back to queryset pk related_object_field: field on the relation pointing back to queryset pk
document_ids: the document ids to count against -- a concrete list/set, document_ids: the document ids to count against -- a concrete list/set,
or a simple (already resolved) queryset of ids. Callers or a simple (already resolved) queryset of ids. Keep
that need this filtered by a complex condition (e.g. a the list small: it is embedded in the statement as
permission check) should resolve it to a concrete list literal parameters.
first if the same ids will be reused across multiple
calls, rather than passing the complex queryset itself
into each -- see ``_get_selection_data_for_queryset``.
target_field: field on the relation pointing to Document id target_field: field on the relation pointing to Document id
""" """
+2 -2
View File
@@ -7,6 +7,8 @@ from dataclasses import dataclass
from types import TracebackType from types import TracebackType
from typing import Self from typing import Self
import dateparser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,8 +80,6 @@ class DateParserPluginBase(ABC):
Uses configuration from `self.config`. Uses configuration from `self.config`.
""" """
import dateparser
try: try:
return dateparser.parse( return dateparser.parse(
date_string, date_string,
+3 -8
View File
@@ -76,6 +76,9 @@ from paperless.logging import consume_task_id
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers.registry import get_parser_registry from paperless.parsers.registry import get_parser_registry
from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.exceptions import LLMTimeoutError
from paperless_ai.indexing import llm_index_add_or_update_document
from paperless_ai.indexing import llm_index_remove_document
from paperless_ai.indexing import update_llm_index
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry from auditlog.models import LogEntry
@@ -336,8 +339,6 @@ def bulk_update_documents(document_ids) -> None:
ai_config = AIConfig() ai_config = AIConfig()
if ai_config.llm_index_enabled: if ai_config.llm_index_enabled:
from paperless_ai.indexing import update_llm_index
update_llm_index( update_llm_index(
rebuild=False, rebuild=False,
document_ids=document_ids, document_ids=document_ids,
@@ -498,8 +499,6 @@ def update_document_content_maybe_archive_file(
ai_config = AIConfig() ai_config = AIConfig()
if ai_config.llm_index_enabled: if ai_config.llm_index_enabled:
from paperless_ai.indexing import llm_index_add_or_update_document
llm_index_add_or_update_document(document) llm_index_add_or_update_document(document)
clear_document_caches(document.pk) clear_document_caches(document.pk)
@@ -801,15 +800,11 @@ def apply_ai_suggestions(self, action_id: int, document_id: int) -> None:
@shared_task @shared_task
def update_document_in_llm_index(document) -> None: def update_document_in_llm_index(document) -> None:
from paperless_ai.indexing import llm_index_add_or_update_document
llm_index_add_or_update_document(document) llm_index_add_or_update_document(document)
@shared_task @shared_task
def remove_document_from_llm_index(document) -> None: def remove_document_from_llm_index(document) -> None:
from paperless_ai.indexing import llm_index_remove_document
llm_index_remove_document(document) llm_index_remove_document(document)
@@ -71,7 +71,7 @@ class TestParseString:
): ):
# We still need to mock dateparser.parse to force the exception # We still need to mock dateparser.parse to force the exception
mocker.patch( mocker.patch(
"dateparser.parse", "documents.plugins.date_parsing.base.dateparser.parse",
side_effect=ValueError( side_effect=ValueError(
"Parsing error: 01/01/2024", "Parsing error: 01/01/2024",
), ),
@@ -260,7 +260,7 @@ class TestRegexDateParser:
parser = RegexDateParserPlugin(base_config) parser = RegexDateParserPlugin(base_config)
# Patch the dateparser.parse # Patch the dateparser.parse
target = "dateparser.parse" target = "documents.plugins.date_parsing.base.dateparser.parse"
def fake_parse( def fake_parse(
date_string: str, date_string: str,
@@ -335,7 +335,7 @@ class TestRegexDateParser:
) )
parser = RegexDateParserPlugin(cfg) parser = RegexDateParserPlugin(cfg)
target = "dateparser.parse" target = "documents.plugins.date_parsing.base.dateparser.parse"
def fake_parse( def fake_parse(
date_string: str, date_string: str,
@@ -400,7 +400,7 @@ class TestRegexDateParser:
parser = RegexDateParserPlugin(cfg) parser = RegexDateParserPlugin(cfg)
# Patch the module's dateparser.parse so we can inspect calls # Patch the module's dateparser.parse so we can inspect calls
target = "dateparser.parse" target = "documents.plugins.date_parsing.base.dateparser.parse"
def fake_parse( def fake_parse(
date_string: str, date_string: str,
+5 -5
View File
@@ -1070,7 +1070,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with ( with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update, patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists") as mock_exists, patch("paperless.views.llm_index_exists") as mock_exists,
): ):
mock_exists.return_value = False mock_exists.return_value = False
self.client.patch( self.client.patch(
@@ -1095,7 +1095,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with ( with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update, patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists", return_value=False), patch("paperless.views.llm_index_exists", return_value=False),
): ):
self.client.patch( self.client.patch(
f"{self.ENDPOINT}1/", f"{self.ENDPOINT}1/",
@@ -1115,7 +1115,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with ( with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update, patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists") as mock_exists, patch("paperless.views.llm_index_exists") as mock_exists,
): ):
mock_exists.return_value = True mock_exists.return_value = True
self.client.patch( self.client.patch(
@@ -1136,7 +1136,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with ( with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update, patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists") as mock_exists, patch("paperless.views.llm_index_exists") as mock_exists,
): ):
mock_exists.return_value = True mock_exists.return_value = True
self.client.patch( self.client.patch(
@@ -1174,7 +1174,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
with ( with (
patch("documents.tasks.llmindex_index.apply_async") as mock_update, patch("documents.tasks.llmindex_index.apply_async") as mock_update,
patch("paperless_ai.indexing.llm_index_exists") as mock_exists, patch("paperless.views.llm_index_exists") as mock_exists,
): ):
mock_exists.return_value = True mock_exists.return_value = True
self.client.patch( self.client.patch(
+2 -2
View File
@@ -61,7 +61,7 @@ class TestChatStreamingViewInputValidation(APITestCase):
return_value=self._mock_ai_enabled(), return_value=self._mock_ai_enabled(),
), ),
mock.patch( mock.patch(
"paperless_ai.chat.stream_chat_with_documents", "documents.views.stream_chat_with_documents",
return_value=iter(chunks), return_value=iter(chunks),
), ),
): ):
@@ -104,7 +104,7 @@ class TestChatStreamingViewUnrestrictedFlag:
""" """
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
return mocker.patch( return mocker.patch(
"paperless_ai.chat.stream_chat_with_documents", "documents.views.stream_chat_with_documents",
return_value=iter(()), return_value=iter(()),
) )
@@ -1110,7 +1110,7 @@ class TestVersionActionPermissions(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
with ( with (
mock.patch("documents.views.AIConfig") as ai_config, mock.patch("documents.views.AIConfig") as ai_config,
mock.patch("paperless_ai.chat.stream_chat_with_documents") as chat, mock.patch("documents.views.stream_chat_with_documents") as chat,
): ):
ai_config.return_value.ai_enabled = True ai_config.return_value.ai_enabled = True
response = self.client.post( response = self.client.post(
+1 -1
View File
@@ -2686,7 +2686,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
response = self.client.get("/api/documents/34676/suggestions/") response = self.client.get("/api/documents/34676/suggestions/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@mock.patch("paperless_ai.ai_classifier.get_ai_document_classification") @mock.patch("documents.views.get_ai_document_classification")
@override_settings(AI_ENABLED=True) @override_settings(AI_ENABLED=True)
def test_suggestions_still_uses_classifier_when_ai_enabled( def test_suggestions_still_uses_classifier_when_ai_enabled(
self, self,
@@ -572,7 +572,7 @@ class TestAiChatAllDocumentsPermissionBoundary:
ENDPOINT = "/api/documents/chat/" ENDPOINT = "/api/documents/chat/"
@override_settings(AI_ENABLED=True) @override_settings(AI_ENABLED=True)
@patch("paperless_ai.chat.stream_chat_with_documents") @patch("documents.views.stream_chat_with_documents")
def test_chat_all_documents_excludes_unshared_document(self, mock_stream_chat): def test_chat_all_documents_excludes_unshared_document(self, mock_stream_chat):
mock_stream_chat.return_value = iter([b"data"]) mock_stream_chat.return_value = iter([b"data"])
+4 -4
View File
@@ -322,7 +322,7 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
) )
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface") @override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface")
@mock.patch("paperless_ai.indexing.llm_index_add_or_update_document") @mock.patch("documents.tasks.llm_index_add_or_update_document")
@mock.patch("documents.search.get_backend") @mock.patch("documents.search.get_backend")
def test_update_content_version_updates_llm_index( def test_update_content_version_updates_llm_index(
self, self,
@@ -450,7 +450,7 @@ class TestAIIndex(DirectoriesMixin, TestCase):
checksum="wow", checksum="wow",
) )
with mock.patch( with mock.patch(
"paperless_ai.indexing.llm_index_add_or_update_document", "documents.tasks.llm_index_add_or_update_document",
) as llm_index_add_or_update_document: ) as llm_index_add_or_update_document:
tasks.update_document_in_llm_index(doc) tasks.update_document_in_llm_index(doc)
llm_index_add_or_update_document.assert_called_once_with(doc) llm_index_add_or_update_document.assert_called_once_with(doc)
@@ -470,7 +470,7 @@ class TestAIIndex(DirectoriesMixin, TestCase):
checksum="wow", checksum="wow",
) )
with mock.patch( with mock.patch(
"paperless_ai.indexing.llm_index_remove_document", "documents.tasks.llm_index_remove_document",
) as llm_index_remove_document: ) as llm_index_remove_document:
tasks.remove_document_from_llm_index(doc) tasks.remove_document_from_llm_index(doc)
llm_index_remove_document.assert_called_once_with(doc) llm_index_remove_document.assert_called_once_with(doc)
@@ -495,7 +495,7 @@ class TestAIIndex(DirectoriesMixin, TestCase):
"documents.tasks.update_document_in_llm_index", "documents.tasks.update_document_in_llm_index",
) as update_document_in_llm_index, ) as update_document_in_llm_index,
mock.patch( mock.patch(
"paperless_ai.indexing.update_llm_index", "documents.tasks.update_llm_index",
) as update_llm_index, ) as update_llm_index,
): ):
doc_ids = [doc.pk for doc in docs] doc_ids = [doc.pk for doc in docs]
+16 -16
View File
@@ -474,7 +474,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], []) self.assertEqual(response.json()["tags"], [])
self.assertEqual(response.json()["suggested_tags"], []) self.assertEqual(response.json()["suggested_tags"], [])
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="mock_backend", LLM_BACKEND="mock_backend",
@@ -524,7 +524,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
None, None,
) )
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="mock_backend", LLM_BACKEND="mock_backend",
@@ -562,7 +562,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
"KI Title", "KI Title",
) )
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="mock_backend", LLM_BACKEND="mock_backend",
@@ -601,7 +601,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
"Titre IA", "Titre IA",
) )
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="mock_backend", LLM_BACKEND="mock_backend",
@@ -637,7 +637,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
), ),
) )
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="mock_backend", LLM_BACKEND="mock_backend",
@@ -709,7 +709,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
), ),
) )
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="openai-like", LLM_BACKEND="openai-like",
@@ -738,7 +738,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"), get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
) )
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="openai-like", LLM_BACKEND="openai-like",
@@ -765,7 +765,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"), get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
) )
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="openai-like", LLM_BACKEND="openai-like",
@@ -797,7 +797,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
get_llm_suggestion_cache(self.document.pk, backend="openai-like"), get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
) )
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="mock_backend", LLM_BACKEND="mock_backend",
@@ -835,7 +835,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], [self.tag1.pk]) self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"]) self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="mock_backend", LLM_BACKEND="mock_backend",
@@ -874,7 +874,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
self.assertEqual(response.json()["tags"], [self.tag1.pk]) self.assertEqual(response.json()["tags"], [self.tag1.pk])
self.assertEqual(response.json()["suggested_tags"], []) self.assertEqual(response.json()["suggested_tags"], [])
@patch("paperless_ai.ai_classifier.get_ai_document_classification") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
LLM_BACKEND="mock_backend", LLM_BACKEND="mock_backend",
@@ -996,7 +996,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn(b"AI is required for this feature", response.content) self.assertIn(b"AI is required for this feature", response.content)
@patch("paperless_ai.chat.stream_chat_with_documents") @patch("documents.views.stream_chat_with_documents")
@patch("documents.views.permitted_document_ids") @patch("documents.views.permitted_document_ids")
@override_settings(AI_ENABLED=True) @override_settings(AI_ENABLED=True)
def test_post_no_document_id(self, mock_permitted_ids, mock_stream_chat) -> None: def test_post_no_document_id(self, mock_permitted_ids, mock_stream_chat) -> None:
@@ -1016,7 +1016,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(list(call_kwargs["documents"]), [self.document]) self.assertEqual(list(call_kwargs["documents"]), [self.document])
self.assertIsNone(call_kwargs["output_language"]) self.assertIsNone(call_kwargs["output_language"])
@patch("paperless_ai.chat.stream_chat_with_documents") @patch("documents.views.stream_chat_with_documents")
@patch("documents.views.permitted_document_ids") @patch("documents.views.permitted_document_ids")
@override_settings(AI_ENABLED=True) @override_settings(AI_ENABLED=True)
def test_post_uses_user_display_language( def test_post_uses_user_display_language(
@@ -1042,7 +1042,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(list(call_kwargs["documents"]), [self.document]) self.assertEqual(list(call_kwargs["documents"]), [self.document])
self.assertEqual(call_kwargs["output_language"], "de-de") self.assertEqual(call_kwargs["output_language"], "de-de")
@patch("paperless_ai.chat.stream_chat_with_documents") @patch("documents.views.stream_chat_with_documents")
@override_settings(AI_ENABLED=True) @override_settings(AI_ENABLED=True)
def test_post_with_document_id(self, mock_stream_chat) -> None: def test_post_with_document_id(self, mock_stream_chat) -> None:
self.grant_view_document_permission() self.grant_view_document_permission()
@@ -1079,7 +1079,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
self.assertIn(b"Insufficient permissions", response.content) self.assertIn(b"Insufficient permissions", response.content)
@patch("paperless_ai.chat.stream_chat_with_documents") @patch("documents.views.stream_chat_with_documents")
@override_settings(AI_ENABLED=True) @override_settings(AI_ENABLED=True)
def test_post_no_document_id_requires_view_document_permission( def test_post_no_document_id_requires_view_document_permission(
self, self,
@@ -1093,7 +1093,7 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
mock_stream_chat.assert_not_called() mock_stream_chat.assert_not_called()
@patch("paperless_ai.chat.stream_chat_with_documents") @patch("documents.views.stream_chat_with_documents")
@override_settings(AI_ENABLED=True) @override_settings(AI_ENABLED=True)
def test_post_with_document_id_requires_view_document_permission( def test_post_with_document_id_requires_view_document_permission(
self, self,
+65 -78
View File
@@ -168,7 +168,6 @@ from documents.permissions import PaperlessNotePermissions
from documents.permissions import PaperlessObjectPermissions from documents.permissions import PaperlessObjectPermissions
from documents.permissions import TrashPermissions from documents.permissions import TrashPermissions
from documents.permissions import ViewDocumentsPermissions from documents.permissions import ViewDocumentsPermissions
from documents.permissions import annotate_document_count_by_ids
from documents.permissions import annotate_document_count_for_related_queryset from documents.permissions import annotate_document_count_for_related_queryset
from documents.permissions import get_document_count_filter_for_user from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_permission_grantees from documents.permissions import get_permission_grantees
@@ -250,8 +249,20 @@ from paperless.parsers.remote import RemoteEngineConfig
from paperless.serialisers import GroupSerializer from paperless.serialisers import GroupSerializer
from paperless.serialisers import UserSerializer from paperless.serialisers import UserSerializer
from paperless.views import StandardPagination from paperless.views import StandardPagination
from paperless_ai.ai_classifier import get_ai_document_classification
from paperless_ai.ai_classifier import get_llm_output_language
from paperless_ai.chat import stream_chat_with_documents
from paperless_ai.exceptions import LLMProviderError from paperless_ai.exceptions import LLMProviderError
from paperless_ai.exceptions import LLMTimeoutError from paperless_ai.exceptions import LLMTimeoutError
from paperless_ai.matching import extract_unmatched_names
from paperless_ai.matching import match_correspondents_by_name
from paperless_ai.matching import match_document_types_by_name
from paperless_ai.matching import match_storage_paths_by_name
from paperless_ai.matching import match_tags_by_name
from paperless_ai.matching import resolve_correspondent_ids
from paperless_ai.matching import resolve_document_type_ids
from paperless_ai.matching import resolve_storage_path_ids
from paperless_ai.matching import resolve_tag_ids
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.oauth import PaperlessMailOAuth2Manager from paperless_mail.oauth import PaperlessMailOAuth2Manager
@@ -1011,70 +1022,61 @@ class DocumentViewSet(
) )
def _get_selection_data_for_queryset(self, queryset): def _get_selection_data_for_queryset(self, queryset):
# Resolve once instead of once per model below. `queryset` can carry an # The filtered queryset (user filters plus the permission filter) is
# arbitrarily expensive WHERE clause (user filters plus the permission # kept as SQL and handed to the database, never resolved to a Python id
# filter); re-embedding it as a subquery inside 5 separate Count(...) # list: at hundreds of thousands of documents that list becomes a
# calls forces the database to re-evaluate that whole thing 5 times, and # multi-MB IN (...) clause per statement, which is slow to plan and, on
# -- for FK relations especially -- can defeat semi-join planning # SQLite, exceeds the bound-parameter limit outright. Each relation
# entirely at scale. A concrete id list is cheap to reuse. # instead gets one GROUP BY with the filter as a plain WHERE
# order_by() drops the default/user ordering -- irrelevant for a plain # (foreign keys) or a semi-join subquery (link tables).
# id list, but left in place it forces a sort over the full filtered # order_by() drops the default/user ordering, which is irrelevant for
# set before the ids can even be collected. # counting and would otherwise force a sort.
document_ids = list(queryset.order_by().values_list("pk", flat=True)) documents = queryset.order_by()
document_ids = documents.values("pk")
def foreign_key_counts(field_name: str) -> dict[int, int]:
# distinct: filters can join (and so repeat) a document row.
return dict(
documents.filter(**{f"{field_name}__isnull": False})
.values_list(field_name)
.annotate(document_count=Count("pk", distinct=True)),
)
def link_table_counts(link_model, field_name: str) -> dict[int, int]:
return dict(
link_model.objects.filter(document_id__in=document_ids)
.values_list(field_name)
.annotate(document_count=Count("document_id")),
)
def selection(model, counts: dict[int, int]) -> list[dict[str, int]]:
# Every object is listed, with 0 for those with no matching document.
return [
{"id": pk, "document_count": counts.get(pk, 0)}
for pk in model.objects.values_list("pk", flat=True)
]
correspondents = Correspondent.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__id__in=document_ids),
distinct=True,
),
)
document_types = DocumentType.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__id__in=document_ids),
distinct=True,
),
)
storage_paths = StoragePath.objects.annotate(
document_count=Count(
"documents",
filter=Q(documents__id__in=document_ids),
distinct=True,
),
)
# Tag and CustomField reach Document through an M2M/through-model table;
# a plain Count(filter=...) there is a much more expensive plan than the
# FK relations above once the bridge table is large -- see
# annotate_document_count_by_ids() for why.
tags = annotate_document_count_by_ids(
Tag.objects.all(),
through_model=Document.tags.through,
related_object_field="tag_id",
document_ids=document_ids,
)
custom_fields = annotate_document_count_by_ids(
CustomField.objects.all(),
through_model=CustomFieldInstance,
related_object_field="field_id",
document_ids=document_ids,
)
return { return {
"selected_correspondents": [ "selected_correspondents": selection(
{"id": t.id, "document_count": t.document_count} for t in correspondents Correspondent,
], foreign_key_counts("correspondent_id"),
"selected_tags": [ ),
{"id": t.id, "document_count": t.document_count} for t in tags "selected_tags": selection(
], Tag,
"selected_document_types": [ link_table_counts(Document.tags.through, "tag_id"),
{"id": t.id, "document_count": t.document_count} for t in document_types ),
], "selected_document_types": selection(
"selected_storage_paths": [ DocumentType,
{"id": t.id, "document_count": t.document_count} for t in storage_paths foreign_key_counts("document_type_id"),
], ),
"selected_custom_fields": [ "selected_storage_paths": selection(
{"id": t.id, "document_count": t.document_count} for t in custom_fields StoragePath,
], foreign_key_counts("storage_path_id"),
),
"selected_custom_fields": selection(
CustomField,
link_table_counts(CustomFieldInstance, "field_id"),
),
} }
@classmethod @classmethod
@@ -1601,18 +1603,6 @@ class DocumentViewSet(
if not ai_config.ai_enabled: if not ai_config.ai_enabled:
return HttpResponseBadRequest("AI is required for this feature") return HttpResponseBadRequest("AI is required for this feature")
from paperless_ai.ai_classifier import get_ai_document_classification
from paperless_ai.ai_classifier import get_llm_output_language
from paperless_ai.matching import extract_unmatched_names
from paperless_ai.matching import match_correspondents_by_name
from paperless_ai.matching import match_document_types_by_name
from paperless_ai.matching import match_storage_paths_by_name
from paperless_ai.matching import match_tags_by_name
from paperless_ai.matching import resolve_correspondent_ids
from paperless_ai.matching import resolve_document_type_ids
from paperless_ai.matching import resolve_storage_path_ids
from paperless_ai.matching import resolve_tag_ids
output_language = get_llm_output_language( output_language = get_llm_output_language(
ai_config=ai_config, ai_config=ai_config,
user=request.user, user=request.user,
@@ -2446,9 +2436,6 @@ class ChatStreamingView(GenericAPIView[Any]):
) )
unrestricted = user_is_unrestricted(request.user) unrestricted = user_is_unrestricted(request.user)
from paperless_ai.ai_classifier import get_llm_output_language
from paperless_ai.chat import stream_chat_with_documents
output_language = get_llm_output_language( output_language = get_llm_output_language(
ai_config=ai_config, ai_config=ai_config,
user=request.user, user=request.user,
+19 -19
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-30 22:26+0000\n" "POT-Creation-Date: 2026-10-29 17:25+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"
@@ -21,43 +21,43 @@ msgstr ""
msgid "Documents" msgid "Documents"
msgstr "" msgstr ""
#: documents/filters.py:336 #: documents/filters.py:337
msgid "Expected a date or a relative date such as \"today\" or \"in 30 days\"." msgid "Expected a date or a relative date such as \"today\" or \"in 30 days\"."
msgstr "" msgstr ""
#: documents/filters.py:514 #: documents/filters.py:513
msgid "Value must be valid JSON." msgid "Value must be valid JSON."
msgstr "" msgstr ""
#: documents/filters.py:535 #: documents/filters.py:534
msgid "Invalid custom field query expression" msgid "Invalid custom field query expression"
msgstr "" msgstr ""
#: documents/filters.py:545 #: documents/filters.py:544
msgid "Invalid expression list. Must be nonempty." msgid "Invalid expression list. Must be nonempty."
msgstr "" msgstr ""
#: documents/filters.py:566 #: documents/filters.py:565
msgid "Invalid logical operator {op!r}" msgid "Invalid logical operator {op!r}"
msgstr "" msgstr ""
#: documents/filters.py:580 #: documents/filters.py:579
msgid "Maximum number of query conditions exceeded." msgid "Maximum number of query conditions exceeded."
msgstr "" msgstr ""
#: documents/filters.py:644 #: documents/filters.py:643
msgid "{name!r} is not a valid custom field." msgid "{name!r} is not a valid custom field."
msgstr "" msgstr ""
#: documents/filters.py:681 #: documents/filters.py:680
msgid "{data_type} does not support query expr {expr!r}." msgid "{data_type} does not support query expr {expr!r}."
msgstr "" msgstr ""
#: documents/filters.py:802 documents/models.py:137 #: documents/filters.py:801 documents/models.py:137
msgid "Maximum nesting depth exceeded." msgid "Maximum nesting depth exceeded."
msgstr "" msgstr ""
#: documents/filters.py:1179 #: documents/filters.py:1178
msgid "Custom field not found" msgid "Custom field not found"
msgstr "" msgstr ""
@@ -1657,7 +1657,7 @@ msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:483 documents/serialisers.py:840 #: documents/serialisers.py:483 documents/serialisers.py:840
#: documents/serialisers.py:2872 documents/views.py:348 documents/views.py:2733 #: documents/serialisers.py:2872 documents/views.py:360 documents/views.py:2730
#: paperless_mail/serialisers.py:156 #: paperless_mail/serialisers.py:156
msgid "Insufficient permissions." msgid "Insufficient permissions."
msgstr "" msgstr ""
@@ -1698,7 +1698,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed." msgid "Duplicate document identifiers are not allowed."
msgstr "" msgstr ""
#: documents/serialisers.py:2958 documents/views.py:4783 #: documents/serialisers.py:2958 documents/views.py:4780
#, python-format #, python-format
msgid "Documents not found: %(ids)s" msgid "Documents not found: %(ids)s"
msgstr "" msgstr ""
@@ -1949,11 +1949,11 @@ msgstr ""
msgid ", " msgid ", "
msgstr "" msgstr ""
#: documents/views.py:303 #: documents/views.py:315
msgid "Specify only one of text, title_search, query, or more_like_id." msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "" msgstr ""
#: documents/views.py:341 documents/views.py:2730 #: documents/views.py:353 documents/views.py:2727
msgid "Invalid more_like_id" msgid "Invalid more_like_id"
msgstr "" msgstr ""
@@ -1969,20 +1969,20 @@ msgstr ""
msgid "AI backend rejected the request. Check logs for details." msgid "AI backend rejected the request. Check logs for details."
msgstr "" msgstr ""
#: documents/views.py:4799 #: documents/views.py:4796
#, python-format #, python-format
msgid "Insufficient permissions to share document %(id)s." msgid "Insufficient permissions to share document %(id)s."
msgstr "" msgstr ""
#: documents/views.py:4846 #: documents/views.py:4843
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4912 #: documents/views.py:4909
msgid "The share link bundle is still being prepared. Please try again later." msgid "The share link bundle is still being prepared. Please try again later."
msgstr "" msgstr ""
#: documents/views.py:4926 #: documents/views.py:4923
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""
+1 -1
View File
@@ -17,7 +17,7 @@ from paperless.urls import websocket_urlpatterns # noqa: E402
application = ProtocolTypeRouter( application = ProtocolTypeRouter(
{ {
"http": django_asgi_app, "http": get_asgi_application(),
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)), "websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
}, },
) )
+3 -4
View File
@@ -36,6 +36,9 @@ from humanize import naturalsize
from imap_tools import MailAttachment from imap_tools import MailAttachment
from imap_tools import MailMessage from imap_tools import MailMessage
from tika_client import TikaClient from tika_client import TikaClient
from turbohtml.clean import Linkify
from turbohtml.clean import linkify
from turbohtml.migration.bleach import clean
from documents.parsers import ParseError from documents.parsers import ParseError
from documents.parsers import make_thumbnail_from_pdf from documents.parsers import make_thumbnail_from_pdf
@@ -619,10 +622,6 @@ class MailDocumentParser:
def clean_html(text: str) -> str: def clean_html(text: str) -> str:
"""Attempt to clean, escape, and linkify the given HTML string.""" """Attempt to clean, escape, and linkify the given HTML string."""
from turbohtml.clean import Linkify
from turbohtml.clean import linkify
from turbohtml.migration.bleach import clean
if isinstance(text, list): if isinstance(text, list):
text = "\n".join([str(e) for e in text]) text = "\n".join([str(e) for e in text])
if not isinstance(text, str): if not isinstance(text, str):
+4 -8
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import functools
import importlib.resources import importlib.resources
import logging import logging
import os import os
@@ -40,12 +39,9 @@ if TYPE_CHECKING:
logger = logging.getLogger("paperless.parsing.tesseract") logger = logging.getLogger("paperless.parsing.tesseract")
_SRGB_ICC_DATA: Final[bytes] = (
@functools.cache importlib.resources.files("ocrmypdf.data").joinpath("sRGB.icc").read_bytes()
def _srgb_icc_data() -> bytes: )
# Read on first use, importing ocrmypdf.data pulls in the whole ocrmypdf package
return importlib.resources.files("ocrmypdf.data").joinpath("sRGB.icc").read_bytes()
_SUPPORTED_MIME_TYPES: Final[dict[str, str]] = { _SUPPORTED_MIME_TYPES: Final[dict[str, str]] = {
"application/pdf": ".pdf", "application/pdf": ".pdf",
@@ -407,7 +403,7 @@ class RasterisedDocumentParser:
pdfa_path = Path(self.tempdir) / "archive.pdf" pdfa_path = Path(self.tempdir) / "archive.pdf"
try: try:
with pikepdf.open(plain_pdf_path) as pdf: with pikepdf.open(plain_pdf_path) as pdf:
cs = pdf.make_stream(_srgb_icc_data()) cs = pdf.make_stream(_SRGB_ICC_DATA)
cs["/N"] = 3 cs["/N"] = 3
output_intent = pikepdf.Dictionary( output_intent = pikepdf.Dictionary(
Type=pikepdf.Name("/OutputIntent"), Type=pikepdf.Name("/OutputIntent"),
+1 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any from typing import Any
from celery.schedules import crontab from celery.schedules import crontab
from dateparser.languages.loader import LocaleDataLoader
from paperless.settings.parsers import get_choice_from_env from paperless.settings.parsers import get_choice_from_env
from paperless.settings.parsers import get_int_from_env from paperless.settings.parsers import get_int_from_env
@@ -351,8 +352,6 @@ def parse_dateparser_languages(languages: str | None) -> list[str]:
) )
language_list.append("zh") language_list.append("zh")
from dateparser.languages.loader import LocaleDataLoader
return list(LocaleDataLoader().get_locale_map(locales=language_list)) return list(LocaleDataLoader().get_locale_map(locales=language_list))
+1 -4
View File
@@ -59,10 +59,7 @@ def test_ocr_to_dateparser_languages_exception(
raise RuntimeError("Simulated error") raise RuntimeError("Simulated error")
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
monkeypatch.setattr( monkeypatch.setattr(utils, "LocaleDataLoader", lambda: DummyLoader())
"dateparser.languages.loader.LocaleDataLoader",
lambda: DummyLoader(),
)
result = utils.ocr_to_dateparser_languages("eng+fra") result = utils.ocr_to_dateparser_languages("eng+fra")
assert result == [] assert result == []
assert ( assert (
+2 -2
View File
@@ -1,5 +1,7 @@
import logging import logging
from dateparser.languages.loader import LocaleDataLoader
logger = logging.getLogger("paperless.utils") logger = logging.getLogger("paperless.utils")
OCR_TO_DATEPARSER_LANGUAGES = { OCR_TO_DATEPARSER_LANGUAGES = {
@@ -123,8 +125,6 @@ def ocr_to_dateparser_languages(ocr_languages: str) -> list[str]:
- If a language cannot be mapped or validated, it is skipped with a warning. - If a language cannot be mapped or validated, it is skipped with a warning.
- Returns a list of valid locales, or an empty list if none could be converted. - Returns a list of valid locales, or an empty list if none could be converted.
""" """
from dateparser.languages.loader import LocaleDataLoader
loader = LocaleDataLoader() loader = LocaleDataLoader()
result = [] result = []
try: try:
+1 -1
View File
@@ -3,6 +3,7 @@ from io import BytesIO
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from lxml import etree
from PIL import Image from PIL import Image
ALLOWED_SVG_TAGS: set[str] = { ALLOWED_SVG_TAGS: set[str] = {
@@ -185,7 +186,6 @@ def reject_dangerous_svg(file: UploadedFile) -> None:
Raises ValidationError if unsafe content is found. Raises ValidationError if unsafe content is found.
See GHSA-6p53-hqqw-8j62 See GHSA-6p53-hqqw-8j62
""" """
from lxml import etree
try: try:
parser = etree.XMLParser(resolve_entities=False) parser = etree.XMLParser(resolve_entities=False)
+1 -2
View File
@@ -49,6 +49,7 @@ from paperless.serialisers import GroupSerializer
from paperless.serialisers import PaperlessAuthTokenSerializer from paperless.serialisers import PaperlessAuthTokenSerializer
from paperless.serialisers import ProfileSerializer from paperless.serialisers import ProfileSerializer
from paperless.serialisers import UserSerializer from paperless.serialisers import UserSerializer
from paperless_ai.indexing import llm_index_exists
class PaperlessObtainAuthTokenView(ObtainAuthToken): class PaperlessObtainAuthTokenView(ObtainAuthToken):
@@ -470,8 +471,6 @@ class ApplicationConfigurationViewSet(ModelViewSet[ApplicationConfiguration]):
or old_llm_embedding_model != new_llm_embedding_model or old_llm_embedding_model != new_llm_embedding_model
or old_llm_context_size != new_llm_context_size or old_llm_context_size != new_llm_context_size
) )
from paperless_ai.indexing import llm_index_exists
rebuild_needed = new_ai_index_enabled and ( rebuild_needed = new_ai_index_enabled and (
not llm_index_exists() or embedding_config_changed not llm_index_exists() or embedding_config_changed
) )