mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-24 02:05:48 -06:00
Compare commits
6 Commits
005ef4fce6
...
69514d8d70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69514d8d70 | ||
|
|
dd6f7fad32 | ||
|
|
c5ad148dc7 | ||
|
|
b12f1e757c | ||
|
|
0cbab1ae80 | ||
|
|
0219df5b67 |
@@ -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.9.7-python3.12-bookworm-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.9.9-python3.12-bookworm-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -747,7 +747,7 @@ export class FilterEditorComponent
|
|||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_TITLE_CONTENT,
|
rule_type: FILTER_TITLE_CONTENT,
|
||||||
value: this._textFilter,
|
value: this._textFilter.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
||||||
@@ -805,7 +805,7 @@ export class FilterEditorComponent
|
|||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
rule_type: FILTER_FULLTEXT_QUERY,
|
||||||
value: this._textFilter,
|
value: this._textFilter.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ class InboxFilter(Filter):
|
|||||||
@extend_schema_field(serializers.CharField)
|
@extend_schema_field(serializers.CharField)
|
||||||
class TitleContentFilter(Filter):
|
class TitleContentFilter(Filter):
|
||||||
def filter(self, qs, value):
|
def filter(self, qs, value):
|
||||||
|
value = value.strip() if isinstance(value, str) else value
|
||||||
if value:
|
if value:
|
||||||
return qs.filter(Q(title__icontains=value) | Q(content__icontains=value))
|
return qs.filter(Q(title__icontains=value) | Q(content__icontains=value))
|
||||||
else:
|
else:
|
||||||
@@ -214,6 +215,7 @@ class CustomFieldFilterSet(FilterSet):
|
|||||||
@extend_schema_field(serializers.CharField)
|
@extend_schema_field(serializers.CharField)
|
||||||
class CustomFieldsFilter(Filter):
|
class CustomFieldsFilter(Filter):
|
||||||
def filter(self, qs, value):
|
def filter(self, qs, value):
|
||||||
|
value = value.strip() if isinstance(value, str) else value
|
||||||
if value:
|
if value:
|
||||||
fields_with_matching_selects = CustomField.objects.filter(
|
fields_with_matching_selects = CustomField.objects.filter(
|
||||||
extra_data__icontains=value,
|
extra_data__icontains=value,
|
||||||
@@ -244,6 +246,7 @@ class CustomFieldsFilter(Filter):
|
|||||||
|
|
||||||
class MimeTypeFilter(Filter):
|
class MimeTypeFilter(Filter):
|
||||||
def filter(self, qs, value):
|
def filter(self, qs, value):
|
||||||
|
value = value.strip() if isinstance(value, str) else value
|
||||||
if value:
|
if value:
|
||||||
return qs.filter(mime_type__icontains=value)
|
return qs.filter(mime_type__icontains=value)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from email import message_from_bytes
|
from email import message_from_bytes
|
||||||
from typing import TYPE_CHECKING
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
|
|
||||||
from documents.data_models import ConsumableDocument
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
@dataclass(frozen=True)
|
||||||
from documents.models import Document
|
class EmailAttachment:
|
||||||
|
path: Path
|
||||||
|
mime_type: str
|
||||||
|
friendly_name: str
|
||||||
|
|
||||||
|
|
||||||
def send_email(
|
def send_email(
|
||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
to: list[str],
|
to: list[str],
|
||||||
attachments: list[Document | ConsumableDocument],
|
attachments: list[EmailAttachment],
|
||||||
*,
|
|
||||||
use_archive: bool,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Send an email with attachments.
|
Send an email with attachments.
|
||||||
@@ -28,8 +29,7 @@ def send_email(
|
|||||||
subject: Email subject
|
subject: Email subject
|
||||||
body: Email body text
|
body: Email body text
|
||||||
to: List of recipient email addresses
|
to: List of recipient email addresses
|
||||||
attachments: List of documents to attach (the list may be empty)
|
attachments: List of attachments
|
||||||
use_archive: Whether to attach archive versions when available
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of emails sent
|
Number of emails sent
|
||||||
@@ -46,47 +46,41 @@ def send_email(
|
|||||||
|
|
||||||
# Something could be renaming the file concurrently so it can't be attached
|
# Something could be renaming the file concurrently so it can't be attached
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with FileLock(settings.MEDIA_LOCK):
|
||||||
for document in attachments:
|
for attachment in attachments:
|
||||||
if isinstance(document, ConsumableDocument):
|
filename = _get_unique_filename(
|
||||||
attachment_path = document.original_file
|
attachment.friendly_name,
|
||||||
friendly_filename = document.original_file.name
|
used_filenames,
|
||||||
else:
|
)
|
||||||
attachment_path = (
|
used_filenames.add(filename)
|
||||||
document.archive_path
|
|
||||||
if use_archive and document.has_archive_version
|
|
||||||
else document.source_path
|
|
||||||
)
|
|
||||||
friendly_filename = _get_unique_filename(
|
|
||||||
document,
|
|
||||||
used_filenames,
|
|
||||||
archive=use_archive and document.has_archive_version,
|
|
||||||
)
|
|
||||||
used_filenames.add(friendly_filename)
|
|
||||||
|
|
||||||
with attachment_path.open("rb") as f:
|
with attachment.path.open("rb") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if document.mime_type == "message/rfc822":
|
if attachment.mime_type == "message/rfc822":
|
||||||
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
|
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
|
||||||
content = message_from_bytes(content)
|
content = message_from_bytes(content)
|
||||||
|
|
||||||
email.attach(
|
email.attach(
|
||||||
filename=friendly_filename,
|
filename=filename,
|
||||||
content=content,
|
content=content,
|
||||||
mimetype=document.mime_type,
|
mimetype=attachment.mime_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
return email.send()
|
return email.send()
|
||||||
|
|
||||||
|
|
||||||
def _get_unique_filename(doc: Document, used_names: set[str], *, archive: bool) -> str:
|
def _get_unique_filename(friendly_name: str, used_names: set[str]) -> str:
|
||||||
"""
|
"""
|
||||||
Constructs a unique friendly filename for the given document.
|
Constructs a unique friendly filename for the given document, append a counter if needed.
|
||||||
|
"""
|
||||||
|
if friendly_name not in used_names:
|
||||||
|
return friendly_name
|
||||||
|
|
||||||
The filename might not be unique enough, so a counter is appended if needed.
|
stem = Path(friendly_name).stem
|
||||||
"""
|
suffix = "".join(Path(friendly_name).suffixes)
|
||||||
counter = 0
|
|
||||||
|
counter = 1
|
||||||
while True:
|
while True:
|
||||||
filename = doc.get_public_filename(archive=archive, counter=counter)
|
filename = f"{stem}_{counter:02}{suffix}"
|
||||||
if filename not in used_names:
|
if filename not in used_names:
|
||||||
return filename
|
return filename
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|||||||
@@ -31,11 +31,10 @@ from guardian.shortcuts import remove_perm
|
|||||||
|
|
||||||
from documents import matching
|
from documents import matching
|
||||||
from documents.caching import clear_document_caches
|
from documents.caching import clear_document_caches
|
||||||
from documents.data_models import ConsumableDocument
|
|
||||||
from documents.data_models import DocumentSource
|
|
||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
from documents.file_handling import delete_empty_directories
|
from documents.file_handling import delete_empty_directories
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
|
from documents.mail import EmailAttachment
|
||||||
from documents.mail import send_email
|
from documents.mail import send_email
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -57,6 +56,7 @@ from documents.templating.workflows import parse_w_workflow_placeholders
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.handlers")
|
logger = logging.getLogger("paperless.handlers")
|
||||||
@@ -1095,7 +1095,9 @@ def run_workflows(
|
|||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
title = document.title
|
title = document.title
|
||||||
doc_url = f"{settings.PAPERLESS_URL}/documents/{document.pk}/"
|
doc_url = (
|
||||||
|
f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/"
|
||||||
|
)
|
||||||
correspondent = (
|
correspondent = (
|
||||||
document.correspondent.name if document.correspondent else ""
|
document.correspondent.name if document.correspondent else ""
|
||||||
)
|
)
|
||||||
@@ -1163,28 +1165,41 @@ def run_workflows(
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
attachments = []
|
attachments: list[EmailAttachment] = []
|
||||||
if action.email.include_document:
|
if action.email.include_document:
|
||||||
|
attachment: EmailAttachment | None = None
|
||||||
if trigger_type in [
|
if trigger_type in [
|
||||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||||
]:
|
] and isinstance(document, Document):
|
||||||
# Updated and scheduled can pass the document directly
|
friendly_name = (
|
||||||
attachments = [document]
|
Path(current_filename).name
|
||||||
|
if current_filename
|
||||||
|
else document.source_path.name
|
||||||
|
)
|
||||||
|
attachment = EmailAttachment(
|
||||||
|
path=document.source_path,
|
||||||
|
mime_type=document.mime_type,
|
||||||
|
friendly_name=friendly_name,
|
||||||
|
)
|
||||||
elif original_file:
|
elif original_file:
|
||||||
# For consumed and added document is not yet saved, so pass the original file
|
friendly_name = (
|
||||||
attachments = [
|
Path(current_filename).name
|
||||||
ConsumableDocument(
|
if current_filename
|
||||||
source=DocumentSource.ApiUpload,
|
else original_file.name
|
||||||
original_file=original_file,
|
)
|
||||||
),
|
attachment = EmailAttachment(
|
||||||
]
|
path=original_file,
|
||||||
|
mime_type=document.mime_type,
|
||||||
|
friendly_name=friendly_name,
|
||||||
|
)
|
||||||
|
if attachment:
|
||||||
|
attachments = [attachment]
|
||||||
n_messages = send_email(
|
n_messages = send_email(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
to=action.email.to.split(","),
|
to=action.email.to.split(","),
|
||||||
attachments=attachments,
|
attachments=attachments,
|
||||||
use_archive=False,
|
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Sent {n_messages} notification email(s) to {action.email.to}",
|
f"Sent {n_messages} notification email(s) to {action.email.to}",
|
||||||
@@ -1199,7 +1214,9 @@ def run_workflows(
|
|||||||
def webhook_action():
|
def webhook_action():
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
title = document.title
|
title = document.title
|
||||||
doc_url = f"{settings.PAPERLESS_URL}/documents/{document.pk}/"
|
doc_url = (
|
||||||
|
f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/"
|
||||||
|
)
|
||||||
correspondent = (
|
correspondent = (
|
||||||
document.correspondent.name if document.correspondent else ""
|
document.correspondent.name if document.correspondent else ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -941,6 +941,23 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(len(results), 0)
|
self.assertEqual(len(results), 0)
|
||||||
|
|
||||||
|
def test_documents_title_content_filter_strips_boundary_whitespace(self):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Testwort",
|
||||||
|
content="",
|
||||||
|
checksum="A",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/documents/",
|
||||||
|
{"title_content": " Testwort "},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
results = response.data["results"]
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertEqual(results[0]["id"], doc.id)
|
||||||
|
|
||||||
def test_document_permissions_filters(self):
|
def test_document_permissions_filters(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from typing import TYPE_CHECKING
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core import mail
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
@@ -21,6 +23,8 @@ from pytest_httpx import HTTPXMock
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from documents.file_handling import create_source_path_directory
|
||||||
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.signals.handlers import send_webhook
|
from documents.signals.handlers import send_webhook
|
||||||
|
|
||||||
@@ -2989,6 +2993,70 @@ class TestWorkflows(
|
|||||||
|
|
||||||
mock_email_send.assert_called_once()
|
mock_email_send.assert_called_once()
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
PAPERLESS_EMAIL_HOST="localhost",
|
||||||
|
EMAIL_ENABLED=True,
|
||||||
|
PAPERLESS_URL="http://localhost:8000",
|
||||||
|
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||||
|
)
|
||||||
|
def test_workflow_email_attachment_uses_storage_filename(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Document updated workflow with include document action
|
||||||
|
- Document stored with formatted storage-path filename
|
||||||
|
WHEN:
|
||||||
|
- Workflow sends an email
|
||||||
|
THEN:
|
||||||
|
- Attachment filename matches the stored filename
|
||||||
|
"""
|
||||||
|
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
email_action = WorkflowActionEmail.objects.create(
|
||||||
|
subject="Test Notification: {doc_title}",
|
||||||
|
body="Test message: {doc_url}",
|
||||||
|
to="me@example.com",
|
||||||
|
include_document=True,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
||||||
|
email=email_action,
|
||||||
|
)
|
||||||
|
workflow = Workflow.objects.create(
|
||||||
|
name="Workflow attachment filename",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
workflow.save()
|
||||||
|
|
||||||
|
storage_path = StoragePath.objects.create(
|
||||||
|
name="Fancy Path",
|
||||||
|
path="formatted/{{ document.pk }}/{{ title }}",
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="workflow doc",
|
||||||
|
correspondent=self.c,
|
||||||
|
checksum="workflow-email-attachment",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
storage_path=storage_path,
|
||||||
|
original_filename="workflow-orig.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
# eg what happens in update_filename_and_move_files
|
||||||
|
generated = generate_unique_filename(doc)
|
||||||
|
destination = (settings.ORIGINALS_DIR / generated).resolve()
|
||||||
|
create_source_path_directory(destination)
|
||||||
|
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
|
||||||
|
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
attachment_names = [att[0] for att in mail.outbox[0].attachments]
|
||||||
|
self.assertEqual(attachment_names, [Path(generated).name])
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
EMAIL_ENABLED=False,
|
EMAIL_ENABLED=False,
|
||||||
)
|
)
|
||||||
@@ -3144,6 +3212,8 @@ class TestWorkflows(
|
|||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
PAPERLESS_URL="http://localhost:8000",
|
PAPERLESS_URL="http://localhost:8000",
|
||||||
|
PAPERLESS_FORCE_SCRIPT_NAME="/paperless",
|
||||||
|
BASE_URL="/paperless/",
|
||||||
)
|
)
|
||||||
@mock.patch("documents.signals.handlers.send_webhook.delay")
|
@mock.patch("documents.signals.handlers.send_webhook.delay")
|
||||||
def test_workflow_webhook_action_body(self, mock_post):
|
def test_workflow_webhook_action_body(self, mock_post):
|
||||||
@@ -3195,7 +3265,7 @@ class TestWorkflows(
|
|||||||
|
|
||||||
mock_post.assert_called_once_with(
|
mock_post.assert_called_once_with(
|
||||||
url="http://paperless-ngx.com",
|
url="http://paperless-ngx.com",
|
||||||
data=f"Test message: http://localhost:8000/documents/{doc.id}/",
|
data=f"Test message: http://localhost:8000/paperless/documents/{doc.id}/",
|
||||||
headers={},
|
headers={},
|
||||||
files=None,
|
files=None,
|
||||||
as_json=False,
|
as_json=False,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ from documents.filters import PaperlessTaskFilterSet
|
|||||||
from documents.filters import ShareLinkFilterSet
|
from documents.filters import ShareLinkFilterSet
|
||||||
from documents.filters import StoragePathFilterSet
|
from documents.filters import StoragePathFilterSet
|
||||||
from documents.filters import TagFilterSet
|
from documents.filters import TagFilterSet
|
||||||
|
from documents.mail import EmailAttachment
|
||||||
from documents.mail import send_email
|
from documents.mail import send_email
|
||||||
from documents.matching import match_correspondents
|
from documents.matching import match_correspondents
|
||||||
from documents.matching import match_document_types
|
from documents.matching import match_document_types
|
||||||
@@ -1216,12 +1217,28 @@ class DocumentViewSet(
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
attachments: list[EmailAttachment] = []
|
||||||
|
for doc in documents:
|
||||||
|
attachment_path = (
|
||||||
|
doc.archive_path
|
||||||
|
if use_archive_version and doc.has_archive_version
|
||||||
|
else doc.source_path
|
||||||
|
)
|
||||||
|
attachments.append(
|
||||||
|
EmailAttachment(
|
||||||
|
path=attachment_path,
|
||||||
|
mime_type=doc.mime_type,
|
||||||
|
friendly_name=doc.get_public_filename(
|
||||||
|
archive=use_archive_version and doc.has_archive_version,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
send_email(
|
send_email(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=message,
|
body=message,
|
||||||
to=addresses,
|
to=addresses,
|
||||||
attachments=documents,
|
attachments=attachments,
|
||||||
use_archive=use_archive_version,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -1863,7 +1880,7 @@ class SearchAutoCompleteView(GenericAPIView):
|
|||||||
user = self.request.user if hasattr(self.request, "user") else None
|
user = self.request.user if hasattr(self.request, "user") else None
|
||||||
|
|
||||||
if "term" in request.query_params:
|
if "term" in request.query_params:
|
||||||
term = request.query_params["term"]
|
term = request.query_params["term"].strip()
|
||||||
else:
|
else:
|
||||||
return HttpResponseBadRequest("Term required")
|
return HttpResponseBadRequest("Term required")
|
||||||
|
|
||||||
|
|||||||
@@ -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-10-28 18:06+0000\n"
|
"POT-Creation-Date: 2025-11-14 16:09+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,39 +21,39 @@ msgstr ""
|
|||||||
msgid "Documents"
|
msgid "Documents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:392
|
#: documents/filters.py:395
|
||||||
msgid "Value must be valid JSON."
|
msgid "Value must be valid JSON."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:411
|
#: documents/filters.py:414
|
||||||
msgid "Invalid custom field query expression"
|
msgid "Invalid custom field query expression"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:421
|
#: documents/filters.py:424
|
||||||
msgid "Invalid expression list. Must be nonempty."
|
msgid "Invalid expression list. Must be nonempty."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:442
|
#: documents/filters.py:445
|
||||||
msgid "Invalid logical operator {op!r}"
|
msgid "Invalid logical operator {op!r}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:456
|
#: documents/filters.py:459
|
||||||
msgid "Maximum number of query conditions exceeded."
|
msgid "Maximum number of query conditions exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:521
|
#: documents/filters.py:524
|
||||||
msgid "{name!r} is not a valid custom field."
|
msgid "{name!r} is not a valid custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:558
|
#: documents/filters.py:561
|
||||||
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:666 documents/models.py:135
|
#: documents/filters.py:669 documents/models.py:135
|
||||||
msgid "Maximum nesting depth exceeded."
|
msgid "Maximum nesting depth exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:851
|
#: documents/filters.py:854
|
||||||
msgid "Custom field not found"
|
msgid "Custom field not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -53,14 +54,33 @@ class TestUrlCanary:
|
|||||||
Verify certain URLs are still available so testing is valid still
|
Verify certain URLs are still available so testing is valid still
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Wikimedia rejects requests without a browser-like User-Agent header and returns 403.
|
@classmethod
|
||||||
_WIKIMEDIA_HEADERS = {
|
def _fetch_wikimedia(cls, url: str) -> httpx.Response:
|
||||||
"User-Agent": (
|
"""
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
Wikimedia occasionally throttles automated requests (HTTP 429). Retry a few
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
times with a short backoff so the tests stay stable, and skip if throttling
|
||||||
"Chrome/123.0.0.0 Safari/537.36"
|
persists.
|
||||||
),
|
"""
|
||||||
}
|
last_resp: httpx.Response | None = None
|
||||||
|
# Wikimedia rejects requests without a browser-like User-Agent header and returns 403.
|
||||||
|
headers = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/123.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for delay in (0, 1, 2):
|
||||||
|
resp = httpx.get(url, headers=headers, timeout=30.0)
|
||||||
|
if resp.status_code != httpx.codes.TOO_MANY_REQUESTS:
|
||||||
|
return resp
|
||||||
|
last_resp = resp
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
pytest.skip(
|
||||||
|
"Wikimedia throttled the canary request with HTTP 429; try rerunning later.",
|
||||||
|
)
|
||||||
|
return last_resp # pragma: no cover
|
||||||
|
|
||||||
def test_online_image_exception_on_not_available(self):
|
def test_online_image_exception_on_not_available(self):
|
||||||
"""
|
"""
|
||||||
@@ -76,11 +96,10 @@ class TestUrlCanary:
|
|||||||
whether this image stays online forever, so here we check if we can detect if is not
|
whether this image stays online forever, so here we check if we can detect if is not
|
||||||
available anymore.
|
available anymore.
|
||||||
"""
|
"""
|
||||||
|
resp = self._fetch_wikimedia(
|
||||||
|
"https://upload.wikimedia.org/wikipedia/en/f/f7/nonexistent.png",
|
||||||
|
)
|
||||||
with pytest.raises(httpx.HTTPStatusError) as exec_info:
|
with pytest.raises(httpx.HTTPStatusError) as exec_info:
|
||||||
resp = httpx.get(
|
|
||||||
"https://upload.wikimedia.org/wikipedia/en/f/f7/nonexistent.png",
|
|
||||||
headers=self._WIKIMEDIA_HEADERS,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
assert exec_info.value.response.status_code == httpx.codes.NOT_FOUND
|
assert exec_info.value.response.status_code == httpx.codes.NOT_FOUND
|
||||||
@@ -100,9 +119,8 @@ class TestUrlCanary:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Now check the URL used in samples/sample.html
|
# Now check the URL used in samples/sample.html
|
||||||
resp = httpx.get(
|
resp = self._fetch_wikimedia(
|
||||||
"https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png",
|
"https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png",
|
||||||
headers=self._WIKIMEDIA_HEADERS,
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user