mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-24 16:48:55 +00:00
Compare commits
4
Commits
209c5d2deb
...
bb5100b3d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb5100b3d8 | ||
|
|
c3b353873b | ||
|
|
3adeda28b4 | ||
|
|
e822e72964 |
No files matched your search
@@ -11,7 +11,7 @@ concurrency:
|
||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.10.x"
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
permissions: {}
|
||||
jobs:
|
||||
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.10.x"
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
jobs:
|
||||
changes:
|
||||
|
||||
@@ -8,7 +8,7 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.10.x"
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
DEFAULT_PYTHON_VERSION: "3.12"
|
||||
permissions: {}
|
||||
jobs:
|
||||
|
||||
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
env:
|
||||
DEFAULT_UV_VERSION: "0.11.x"
|
||||
jobs:
|
||||
generate-translate-strings:
|
||||
name: Generate Translation Strings
|
||||
@@ -29,6 +31,7 @@ jobs:
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
- name: Install backend python dependencies
|
||||
run: |
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.10.9-python3.12-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -357,12 +357,13 @@ and the script does the rest of the work:
|
||||
document_importer source
|
||||
```
|
||||
|
||||
| Option | Required | Default | Description |
|
||||
| ------------------- | -------- | ------- | ------------------------------------------------------------------------- |
|
||||
| source | Yes | N/A | The directory containing an export |
|
||||
| `--no-progress-bar` | No | False | If provided, the progress bar will be hidden |
|
||||
| `--data-only` | No | False | If provided, only import data, do not import document files or thumbnails |
|
||||
| `--passphrase` | No | N/A | If your export was encrypted with a passphrase, must be provided |
|
||||
| Option | Required | Default | Description |
|
||||
| ------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| source | Yes | N/A | The directory containing an export |
|
||||
| `--no-progress-bar` | No | False | If provided, the progress bar will be hidden |
|
||||
| `--data-only` | No | False | If provided, only import data, do not import document files or thumbnails |
|
||||
| `--passphrase` | No | N/A | If your export was encrypted with a passphrase, must be provided |
|
||||
| `--batch-size` | No | 500 | Number of database records inserted per batch. Lower values reduce peak memory usage on very large installs. |
|
||||
|
||||
When you use the provided docker compose script, put the export inside
|
||||
the `export` folder in your paperless source directory. Specify
|
||||
|
||||
+55
-64
@@ -5,6 +5,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
from typing import NamedTuple
|
||||
|
||||
from celery import chord
|
||||
from celery import group
|
||||
@@ -46,6 +47,11 @@ class SourceModeChoices:
|
||||
EXPLICIT_SELECTION: SourceMode = "explicit_selection"
|
||||
|
||||
|
||||
class ResolvedDocPair(NamedTuple):
|
||||
root_doc: Document
|
||||
source_doc: Document
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def restore_archive_serial_numbers_task(
|
||||
self,
|
||||
@@ -86,17 +92,20 @@ def _resolve_root_and_source_doc(
|
||||
doc: Document,
|
||||
*,
|
||||
source_mode: SourceMode = SourceModeChoices.LATEST_VERSION,
|
||||
) -> tuple[Document, Document]:
|
||||
) -> ResolvedDocPair:
|
||||
root_doc = get_root_document(doc)
|
||||
|
||||
if source_mode == SourceModeChoices.EXPLICIT_SELECTION:
|
||||
return root_doc, doc
|
||||
return ResolvedDocPair(root_doc=root_doc, source_doc=doc)
|
||||
|
||||
# Version IDs are explicit by default, only a selected root resolves to latest
|
||||
if doc.root_document_id is not None:
|
||||
return root_doc, doc
|
||||
return ResolvedDocPair(root_doc=root_doc, source_doc=doc)
|
||||
|
||||
return root_doc, get_latest_version_for_root(root_doc)
|
||||
return ResolvedDocPair(
|
||||
root_doc=root_doc,
|
||||
source_doc=get_latest_version_for_root(root_doc),
|
||||
)
|
||||
|
||||
|
||||
def set_correspondent(
|
||||
@@ -438,39 +447,36 @@ def rotate(
|
||||
id__in=doc_ids,
|
||||
)
|
||||
}
|
||||
docs_by_root_id: dict[int, tuple[Document, Document]] = {}
|
||||
docs_by_root_id: dict[int, ResolvedDocPair] = {}
|
||||
for doc_id in doc_ids:
|
||||
doc = docs_by_id.get(doc_id)
|
||||
if doc is None:
|
||||
continue
|
||||
root_doc, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
docs_by_root_id.setdefault(root_doc.id, (root_doc, source_doc))
|
||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||
docs_by_root_id.setdefault(pair.root_doc.id, pair)
|
||||
|
||||
import pikepdf
|
||||
|
||||
for root_doc, source_doc in docs_by_root_id.values():
|
||||
if source_doc.mime_type != "application/pdf":
|
||||
for pair in docs_by_root_id.values():
|
||||
if pair.source_doc.mime_type != "application/pdf":
|
||||
logger.warning(
|
||||
f"Document {root_doc.id} is not a PDF, skipping rotation.",
|
||||
f"Document {pair.root_doc.id} is not a PDF, skipping rotation.",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
# Write rotated output to a temp file and create a new version via consume pipeline
|
||||
filepath: Path = (
|
||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||
/ f"{root_doc.id}_rotated.pdf"
|
||||
/ f"{pair.root_doc.id}_rotated.pdf"
|
||||
)
|
||||
with pikepdf.open(source_doc.source_path) as pdf:
|
||||
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||
for page in pdf.pages:
|
||||
page.rotate(degrees, relative=True)
|
||||
pdf.remove_unreferenced_resources()
|
||||
pdf.save(filepath)
|
||||
|
||||
# Preserve metadata/permissions via overrides; mark as new version
|
||||
overrides = DocumentMetadataOverrides().from_document(root_doc)
|
||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||
if user is not None:
|
||||
overrides.actor_id = user.id
|
||||
|
||||
@@ -479,17 +485,17 @@ def rotate(
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=root_doc.id,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
"overrides": overrides,
|
||||
},
|
||||
headers={"trigger_source": trigger_source},
|
||||
)
|
||||
logger.info(
|
||||
f"Queued new rotated version for document {root_doc.id} by {degrees} degrees",
|
||||
f"Queued new rotated version for document {pair.root_doc.id} by {degrees} degrees",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error rotating document {root_doc.id}: {e}")
|
||||
logger.exception(f"Error rotating document {pair.root_doc.id}: {e}")
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -520,17 +526,14 @@ def merge(
|
||||
doc = docs_by_id.get(doc_id)
|
||||
if doc is None:
|
||||
continue
|
||||
_, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||
try:
|
||||
doc_path = (
|
||||
source_doc.archive_path
|
||||
pair.source_doc.archive_path
|
||||
if archive_fallback
|
||||
and source_doc.mime_type != "application/pdf"
|
||||
and source_doc.has_archive_version
|
||||
else source_doc.source_path
|
||||
and pair.source_doc.mime_type != "application/pdf"
|
||||
and pair.source_doc.has_archive_version
|
||||
else pair.source_doc.source_path
|
||||
)
|
||||
with pikepdf.open(str(doc_path)) as pdf:
|
||||
version = max(version, pdf.pdf_version)
|
||||
@@ -620,16 +623,13 @@ def split(
|
||||
f"Attempting to split document {doc_ids[0]} into {len(pages)} documents",
|
||||
)
|
||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||
_, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||
import pikepdf
|
||||
|
||||
consume_tasks = []
|
||||
|
||||
try:
|
||||
with pikepdf.open(source_doc.source_path) as pdf:
|
||||
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||
for idx, split_doc in enumerate(pages):
|
||||
dst: pikepdf.Pdf = pikepdf.new()
|
||||
for page in split_doc:
|
||||
@@ -701,10 +701,7 @@ def delete_pages(
|
||||
f"Attempting to delete pages {pages} from {len(doc_ids)} documents",
|
||||
)
|
||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||
root_doc, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||
pages = sorted(pages) # sort pages to avoid index issues
|
||||
import pikepdf
|
||||
|
||||
@@ -712,9 +709,9 @@ def delete_pages(
|
||||
# Produce edited PDF to a temp file and create a new version
|
||||
filepath: Path = (
|
||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||
/ f"{root_doc.id}_pages_deleted.pdf"
|
||||
/ f"{pair.root_doc.id}_pages_deleted.pdf"
|
||||
)
|
||||
with pikepdf.open(source_doc.source_path) as pdf:
|
||||
with pikepdf.open(pair.source_doc.source_path) as pdf:
|
||||
offset = 1 # pages are 1-indexed
|
||||
for page_num in pages:
|
||||
pdf.pages.remove(pdf.pages[page_num - offset])
|
||||
@@ -722,7 +719,7 @@ def delete_pages(
|
||||
pdf.remove_unreferenced_resources()
|
||||
pdf.save(filepath)
|
||||
|
||||
overrides = DocumentMetadataOverrides().from_document(root_doc)
|
||||
overrides = DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||
if user is not None:
|
||||
overrides.actor_id = user.id
|
||||
consume_file.apply_async(
|
||||
@@ -730,17 +727,17 @@ def delete_pages(
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=root_doc.id,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
"overrides": overrides,
|
||||
},
|
||||
headers={"trigger_source": trigger_source},
|
||||
)
|
||||
logger.info(
|
||||
f"Queued new version for document {root_doc.id} after deleting pages {pages}",
|
||||
f"Queued new version for document {pair.root_doc.id} after deleting pages {pages}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error deleting pages from document {root_doc.id}: {e}")
|
||||
logger.exception(f"Error deleting pages from document {pair.root_doc.id}: {e}")
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -768,16 +765,13 @@ def edit_pdf(
|
||||
f"Editing PDF of document {doc_ids[0]} with {len(operations)} operations",
|
||||
)
|
||||
doc = Document.objects.select_related("root_document").get(id=doc_ids[0])
|
||||
root_doc, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||
import pikepdf
|
||||
|
||||
pdf_docs: list[pikepdf.Pdf] = []
|
||||
|
||||
try:
|
||||
with pikepdf.open(source_doc.source_path) as src:
|
||||
with pikepdf.open(pair.source_doc.source_path) as src:
|
||||
# prepare output documents
|
||||
max_idx = max(op.get("doc", 0) for op in operations)
|
||||
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
|
||||
@@ -801,11 +795,11 @@ def edit_pdf(
|
||||
pdf.remove_unreferenced_resources()
|
||||
filepath: Path = (
|
||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||
/ f"{root_doc.id}_edited.pdf"
|
||||
/ f"{pair.root_doc.id}_edited.pdf"
|
||||
)
|
||||
pdf.save(filepath)
|
||||
overrides = (
|
||||
DocumentMetadataOverrides().from_document(root_doc)
|
||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||
if include_metadata
|
||||
else DocumentMetadataOverrides()
|
||||
)
|
||||
@@ -817,7 +811,7 @@ def edit_pdf(
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=root_doc.id,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
"overrides": overrides,
|
||||
},
|
||||
@@ -826,7 +820,7 @@ def edit_pdf(
|
||||
else:
|
||||
consume_tasks = []
|
||||
overrides = (
|
||||
DocumentMetadataOverrides().from_document(root_doc)
|
||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||
if include_metadata
|
||||
else DocumentMetadataOverrides()
|
||||
)
|
||||
@@ -836,11 +830,11 @@ def edit_pdf(
|
||||
if not delete_original:
|
||||
overrides.skip_asn_if_exists = True
|
||||
if delete_original and len(pdf_docs) == 1:
|
||||
overrides.asn = root_doc.archive_serial_number
|
||||
overrides.asn = pair.root_doc.archive_serial_number
|
||||
for idx, pdf in enumerate(pdf_docs, start=1):
|
||||
version_filepath: Path = (
|
||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||
/ f"{root_doc.id}_edit_{idx}.pdf"
|
||||
/ f"{pair.root_doc.id}_edit_{idx}.pdf"
|
||||
)
|
||||
pdf.remove_unreferenced_resources()
|
||||
pdf.save(version_filepath)
|
||||
@@ -870,7 +864,7 @@ def edit_pdf(
|
||||
group(consume_tasks).delay()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error editing document {root_doc.id}: {e}")
|
||||
logger.exception(f"Error editing document {pair.root_doc.id}: {e}")
|
||||
raise ValueError(
|
||||
f"An error occurred while editing the document: {e}",
|
||||
) from e
|
||||
@@ -896,18 +890,15 @@ def remove_password(
|
||||
|
||||
for doc_id in doc_ids:
|
||||
doc = Document.objects.select_related("root_document").get(id=doc_id)
|
||||
root_doc, source_doc = _resolve_root_and_source_doc(
|
||||
doc,
|
||||
source_mode=source_mode,
|
||||
)
|
||||
pair = _resolve_root_and_source_doc(doc, source_mode=source_mode)
|
||||
try:
|
||||
logger.info(
|
||||
f"Attempting password removal from document {doc_ids[0]}",
|
||||
)
|
||||
with pikepdf.open(source_doc.source_path, password=password) as pdf:
|
||||
with pikepdf.open(pair.source_doc.source_path, password=password) as pdf:
|
||||
filepath: Path = (
|
||||
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
|
||||
/ f"{root_doc.id}_unprotected.pdf"
|
||||
/ f"{pair.root_doc.id}_unprotected.pdf"
|
||||
)
|
||||
pdf.remove_unreferenced_resources()
|
||||
pdf.save(filepath)
|
||||
@@ -915,7 +906,7 @@ def remove_password(
|
||||
if update_document:
|
||||
# Create a new version rather than modifying the root/original in place.
|
||||
overrides = (
|
||||
DocumentMetadataOverrides().from_document(root_doc)
|
||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||
if include_metadata
|
||||
else DocumentMetadataOverrides()
|
||||
)
|
||||
@@ -927,7 +918,7 @@ def remove_password(
|
||||
"input_doc": ConsumableDocument(
|
||||
source=DocumentSource.ConsumeFolder,
|
||||
original_file=filepath,
|
||||
root_document_id=root_doc.id,
|
||||
root_document_id=pair.root_doc.id,
|
||||
),
|
||||
"overrides": overrides,
|
||||
},
|
||||
@@ -936,7 +927,7 @@ def remove_password(
|
||||
else:
|
||||
consume_tasks = []
|
||||
overrides = (
|
||||
DocumentMetadataOverrides().from_document(root_doc)
|
||||
DocumentMetadataOverrides().from_document(pair.root_doc)
|
||||
if include_metadata
|
||||
else DocumentMetadataOverrides()
|
||||
)
|
||||
@@ -964,7 +955,7 @@ def remove_password(
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Error removing password from document {root_doc.id}: {e}",
|
||||
f"Error removing password from document {pair.root_doc.id}: {e}",
|
||||
)
|
||||
raise ValueError(
|
||||
f"An error occurred while removing the password: {e}",
|
||||
|
||||
@@ -365,6 +365,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
|
||||
# 2. Create manifest, containing all correspondents, types, tags, storage paths
|
||||
# note, documents and ui_settings
|
||||
_excluded_usernames = ["consumer", "AnonymousUser"]
|
||||
manifest_key_to_object_query: dict[str, QuerySet[Any]] = {
|
||||
"correspondents": Correspondent.objects.all(),
|
||||
"tags": Tag.objects.all(),
|
||||
@@ -376,12 +377,16 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
"saved_view_filter_rules": SavedViewFilterRule.objects.all(),
|
||||
"groups": Group.objects.all(),
|
||||
"users": User.objects.exclude(
|
||||
username__in=["consumer", "AnonymousUser"],
|
||||
username__in=_excluded_usernames,
|
||||
).all(),
|
||||
"ui_settings": UiSettings.objects.all(),
|
||||
"ui_settings": UiSettings.objects.exclude(
|
||||
user__username__in=_excluded_usernames,
|
||||
),
|
||||
"content_types": ContentType.objects.all(),
|
||||
"permissions": Permission.objects.all(),
|
||||
"user_object_permissions": UserObjectPermission.objects.all(),
|
||||
"user_object_permissions": UserObjectPermission.objects.exclude(
|
||||
user__username__in=_excluded_usernames,
|
||||
),
|
||||
"group_object_permissions": GroupObjectPermission.objects.all(),
|
||||
"workflow_triggers": WorkflowTrigger.objects.all(),
|
||||
"workflow_actions": WorkflowAction.objects.all(),
|
||||
@@ -395,10 +400,16 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
"documents": Document.global_objects.order_by("id").all(),
|
||||
"share_links": ShareLink.global_objects.all(),
|
||||
"share_link_bundles": ShareLinkBundle.objects.order_by("id").all(),
|
||||
"social_accounts": SocialAccount.objects.all(),
|
||||
"social_accounts": SocialAccount.objects.exclude(
|
||||
user__username__in=_excluded_usernames,
|
||||
),
|
||||
"social_apps": SocialApp.objects.all(),
|
||||
"social_tokens": SocialToken.objects.all(),
|
||||
"authenticators": Authenticator.objects.all(),
|
||||
"social_tokens": SocialToken.objects.exclude(
|
||||
account__user__username__in=_excluded_usernames,
|
||||
),
|
||||
"authenticators": Authenticator.objects.exclude(
|
||||
user__username__in=_excluded_usernames,
|
||||
),
|
||||
}
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
|
||||
@@ -2,13 +2,16 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import TypeAlias
|
||||
from zipfile import ZipFile
|
||||
from zipfile import is_zipfile
|
||||
|
||||
import ijson
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
@@ -16,9 +19,14 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.management.color import no_style
|
||||
from django.core.serializers.base import DeserializationError
|
||||
from django.db import IntegrityError
|
||||
from django.db import connection
|
||||
from django.db import models as django_models
|
||||
from django.db import transaction
|
||||
from django.db.models import GeneratedField
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.db.models.signals import post_save
|
||||
from filelock import FileLock
|
||||
@@ -47,6 +55,9 @@ from paperless import version
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
# Maps M2M field names to the list of related PKs to apply after bulk_create.
|
||||
M2MData: TypeAlias = dict[str, list[int]]
|
||||
|
||||
|
||||
def iter_manifest_records(path: Path) -> Generator[dict, None, None]:
|
||||
"""Yield records one at a time from a manifest JSON array via ijson."""
|
||||
@@ -57,6 +68,107 @@ def iter_manifest_records(path: Path) -> Generator[dict, None, None]:
|
||||
raise CommandError(f"Failed to parse manifest file {path}: {e}") from e
|
||||
|
||||
|
||||
def _deserialize_record(
|
||||
record: dict,
|
||||
) -> tuple[type[Model], Model, M2MData]:
|
||||
"""
|
||||
Convert a single manifest record dict into a model instance and M2M data.
|
||||
|
||||
Returns (Model class, unsaved instance, m2m_data) where m2m_data maps
|
||||
M2M field names to lists of integer PKs to be applied after the instance
|
||||
is saved via bulk_create.
|
||||
|
||||
Raises DeserializationError for unknown models or bad field values.
|
||||
Raises FieldDoesNotExist for fields not present on the model.
|
||||
|
||||
Note: CommandError from iter_manifest_records (malformed JSON mid-stream)
|
||||
propagates through the caller unchanged, it is not caught here.
|
||||
"""
|
||||
model_label = record["model"]
|
||||
pk_value = record.get("pk")
|
||||
|
||||
try:
|
||||
Model = apps.get_model(model_label)
|
||||
except (LookupError, TypeError) as e:
|
||||
raise DeserializationError(
|
||||
f"Invalid model identifier: {model_label}",
|
||||
) from e
|
||||
|
||||
data: dict = {}
|
||||
m2m_data: M2MData = {}
|
||||
|
||||
try:
|
||||
data[Model._meta.pk.attname] = Model._meta.pk.to_python(pk_value)
|
||||
except Exception as e:
|
||||
raise DeserializationError(
|
||||
f"Could not coerce pk={pk_value} for {model_label}: {e}",
|
||||
) from e
|
||||
|
||||
for field_name, field_value in record.get("fields", {}).items():
|
||||
field = Model._meta.get_field(field_name)
|
||||
remote = field.remote_field
|
||||
|
||||
if isinstance(remote, django_models.ManyToManyRel):
|
||||
# Collect M2M PKs; .set() is called after bulk_create in flush_model.
|
||||
target_pk = field.related_model._meta.pk
|
||||
m2m_data[field.name] = [
|
||||
target_pk.to_python(pk) for pk in (field_value or [])
|
||||
]
|
||||
|
||||
elif isinstance(remote, django_models.ManyToOneRel):
|
||||
# FK: store the integer PK on field.attname (e.g. correspondent_id)
|
||||
# to avoid triggering the descriptor and avoid an extra DB lookup.
|
||||
if field_value is None:
|
||||
data[field.attname] = None
|
||||
else:
|
||||
data[field.attname] = field.related_model._meta.pk.to_python(
|
||||
field_value,
|
||||
)
|
||||
|
||||
else:
|
||||
try:
|
||||
data[field.name] = field.to_python(field_value)
|
||||
except Exception as e:
|
||||
raise DeserializationError(
|
||||
f"Could not coerce {field_name}={field_value!r} "
|
||||
f"for {model_label}(pk={pk_value}): {e}",
|
||||
) from e
|
||||
|
||||
return Model, Model(**data), m2m_data
|
||||
|
||||
|
||||
def _iter_document_copy_records(
|
||||
manifest_paths: list[Path],
|
||||
) -> Generator[dict, None, None]:
|
||||
"""Yield one lightweight dict per Document record without buffering all records."""
|
||||
for manifest_path in manifest_paths:
|
||||
for record in iter_manifest_records(manifest_path):
|
||||
if record["model"] == "documents.document":
|
||||
yield {
|
||||
"pk": record["pk"],
|
||||
EXPORTER_FILE_NAME: record[EXPORTER_FILE_NAME],
|
||||
EXPORTER_THUMBNAIL_NAME: record.get(EXPORTER_THUMBNAIL_NAME),
|
||||
EXPORTER_ARCHIVE_NAME: record.get(EXPORTER_ARCHIVE_NAME),
|
||||
}
|
||||
|
||||
|
||||
def _iter_share_link_bundle_copy_records(
|
||||
manifest_paths: list[Path],
|
||||
) -> Generator[dict, None, None]:
|
||||
"""Yield one dict per ShareLinkBundle record that has a bundle file."""
|
||||
for manifest_path in manifest_paths:
|
||||
for record in iter_manifest_records(manifest_path):
|
||||
if record["model"] == "documents.sharelinkbundle" and record.get(
|
||||
EXPORTER_SHARE_LINK_BUNDLE_NAME,
|
||||
):
|
||||
yield {
|
||||
"pk": record["pk"],
|
||||
EXPORTER_SHARE_LINK_BUNDLE_NAME: record[
|
||||
EXPORTER_SHARE_LINK_BUNDLE_NAME
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def disable_signal(sig, receiver, sender, *, weak: bool | None = None) -> Generator:
|
||||
try:
|
||||
@@ -92,6 +204,14 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
help="If provided, is used to sensitive fields in the export",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=500,
|
||||
help="Number of records to insert per batch during database load. "
|
||||
"Lower values reduce peak memory usage.",
|
||||
)
|
||||
|
||||
def pre_check(self) -> None:
|
||||
"""
|
||||
Runs some initial checks against the state of the install and source, including:
|
||||
@@ -197,36 +317,117 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
),
|
||||
)
|
||||
|
||||
def _finalize_db_load(self, loaded_models: set[type[Model]]) -> None:
|
||||
"""Verify referential integrity and reset auto-increment sequences."""
|
||||
through_tables = {
|
||||
field.remote_field.through._meta.db_table
|
||||
for model in loaded_models
|
||||
for field in model._meta.many_to_many
|
||||
if field.remote_field.through is not None
|
||||
and field.remote_field.through._meta.auto_created
|
||||
}
|
||||
table_names = [m._meta.db_table for m in loaded_models] + list(through_tables)
|
||||
if table_names:
|
||||
connection.check_constraints(table_names=table_names)
|
||||
|
||||
if loaded_models:
|
||||
sequence_sql = connection.ops.sequence_reset_sql(
|
||||
no_style(),
|
||||
list(loaded_models),
|
||||
)
|
||||
with connection.cursor() as cursor:
|
||||
for sql in sequence_sql:
|
||||
cursor.execute(sql) # pragma: no cover
|
||||
|
||||
def _import_error_context_message(self) -> str:
|
||||
"""Return a diagnostic string explaining a DB import failure."""
|
||||
if ( # pragma: no cover
|
||||
self.version is not None and self.version != version.__full_version_str__
|
||||
):
|
||||
return ( # pragma: no cover
|
||||
"Version mismatch: "
|
||||
f"Currently {version.__full_version_str__},"
|
||||
f" importing {self.version}"
|
||||
)
|
||||
return "No version information present"
|
||||
|
||||
def load_data_to_database(self) -> None:
|
||||
"""
|
||||
As the name implies, loads data from the JSON file(s) into the database
|
||||
Streams records from each manifest path and loads them into the database
|
||||
using bulk_create with bounded batch sizes, avoiding holding the entire
|
||||
manifest in memory at once.
|
||||
|
||||
Memory bound: at most batch_size * (number of distinct model types
|
||||
present simultaneously in the manifest) instances at any time.
|
||||
For the standard non-split manifest, records are grouped by model, so
|
||||
in practice only one model's batch accumulates at a time.
|
||||
"""
|
||||
# Maps model class -> list of (instance, m2m_data) waiting to be flushed
|
||||
pending: defaultdict[type[Model], list[tuple[Model, M2MData]]] = defaultdict(
|
||||
list,
|
||||
)
|
||||
# All model classes inserted (needed for sequence reset after the load)
|
||||
loaded_models: set[type[Model]] = set()
|
||||
|
||||
def flush_model(model: type[Model]) -> None:
|
||||
"""bulk_create the pending batch for model, then apply M2M."""
|
||||
batch = pending.pop(model, [])
|
||||
if not batch: # pragma: no cover
|
||||
return
|
||||
instances = [inst for inst, _ in batch]
|
||||
# GeneratedField is excluded because it is generated and trying to insert it will fail
|
||||
update_fields = [
|
||||
f.attname
|
||||
for f in model._meta.concrete_fields
|
||||
if not f.primary_key and not isinstance(f, GeneratedField)
|
||||
]
|
||||
if not update_fields: # pragma: no cover
|
||||
raise DeserializationError(
|
||||
f"{model.__name__} has no updatable fields; PK-only models are not supported by the importer",
|
||||
)
|
||||
model.objects.bulk_create( # type: ignore[attr-defined]
|
||||
instances,
|
||||
update_conflicts=True,
|
||||
unique_fields=[model._meta.pk.attname],
|
||||
update_fields=update_fields,
|
||||
)
|
||||
loaded_models.add(model)
|
||||
for instance, m2m_data in batch:
|
||||
for field_name, pk_list in m2m_data.items():
|
||||
getattr(instance, field_name).set(pk_list)
|
||||
|
||||
def flush_all() -> None:
|
||||
for model in list(pending):
|
||||
flush_model(model)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# delete these since pk can change, re-created from import
|
||||
# ContentType and Permission have auto-assigned PKs on a fresh
|
||||
# install that conflict with exported PKs. Delete and re-import.
|
||||
ContentType.objects.all().delete()
|
||||
Permission.objects.all().delete()
|
||||
for manifest_path in self.manifest_paths:
|
||||
call_command("loaddata", manifest_path, skip_checks=True)
|
||||
except (FieldDoesNotExist, DeserializationError, IntegrityError) as e:
|
||||
|
||||
# Constraint checks are disabled so FK/M2M inserts succeed
|
||||
# regardless of record order within the manifest.
|
||||
# Note: on SQLite inside a transaction this context manager is a
|
||||
# no-op; the constraint-deferral path is only exercised on
|
||||
# PostgreSQL in production.
|
||||
with connection.constraint_checks_disabled():
|
||||
for manifest_path in self.manifest_paths:
|
||||
for record in iter_manifest_records(manifest_path):
|
||||
model, instance, m2m_data = _deserialize_record(record)
|
||||
pending[model].append((instance, m2m_data))
|
||||
if len(pending[model]) >= self.batch_size:
|
||||
flush_model(model)
|
||||
|
||||
flush_all()
|
||||
|
||||
self._finalize_db_load(loaded_models)
|
||||
|
||||
except (FieldDoesNotExist, DeserializationError, IntegrityError):
|
||||
self.stdout.write(self.style.ERROR("Database import failed"))
|
||||
if (
|
||||
self.version is not None
|
||||
and self.version != version.__full_version_str__
|
||||
): # pragma: no cover
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"Version mismatch: "
|
||||
f"Currently {version.__full_version_str__},"
|
||||
f" importing {self.version}",
|
||||
),
|
||||
)
|
||||
raise e
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR("No version information present"),
|
||||
)
|
||||
raise e
|
||||
self.stdout.write(self.style.ERROR(self._import_error_context_message()))
|
||||
raise
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
logging.getLogger().handlers[0].level = logging.ERROR
|
||||
@@ -234,6 +435,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
self.source = Path(options["source"]).resolve()
|
||||
self.data_only: bool = options["data_only"]
|
||||
self.passphrase: str | None = options.get("passphrase")
|
||||
self.batch_size: int = options["batch_size"]
|
||||
self.version: str | None = None
|
||||
self.salt: str | None = None
|
||||
self.manifest_paths = []
|
||||
@@ -389,31 +591,10 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
|
||||
self.stdout.write("Copy files into paperless...")
|
||||
|
||||
document_records = [
|
||||
{
|
||||
"pk": record["pk"],
|
||||
EXPORTER_FILE_NAME: record[EXPORTER_FILE_NAME],
|
||||
EXPORTER_THUMBNAIL_NAME: record.get(EXPORTER_THUMBNAIL_NAME),
|
||||
EXPORTER_ARCHIVE_NAME: record.get(EXPORTER_ARCHIVE_NAME),
|
||||
}
|
||||
for manifest_path in self.manifest_paths
|
||||
for record in iter_manifest_records(manifest_path)
|
||||
if record["model"] == "documents.document"
|
||||
]
|
||||
share_link_bundle_records = [
|
||||
{
|
||||
"pk": record["pk"],
|
||||
EXPORTER_SHARE_LINK_BUNDLE_NAME: record.get(
|
||||
EXPORTER_SHARE_LINK_BUNDLE_NAME,
|
||||
),
|
||||
}
|
||||
for manifest_path in self.manifest_paths
|
||||
for record in iter_manifest_records(manifest_path)
|
||||
if record["model"] == "documents.sharelinkbundle"
|
||||
and record.get(EXPORTER_SHARE_LINK_BUNDLE_NAME)
|
||||
]
|
||||
|
||||
for record in self.track(document_records, description="Copying files..."):
|
||||
for record in self.track(
|
||||
_iter_document_copy_records(self.manifest_paths),
|
||||
description="Copying files...",
|
||||
):
|
||||
document = Document.global_objects.get(pk=record["pk"])
|
||||
|
||||
doc_file = record[EXPORTER_FILE_NAME]
|
||||
@@ -452,10 +633,8 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
# archived files
|
||||
copy_file_with_basic_stats(archive_path, document.archive_path)
|
||||
|
||||
document.save()
|
||||
|
||||
for record in self.track(
|
||||
share_link_bundle_records,
|
||||
_iter_share_link_bundle_copy_records(self.manifest_paths),
|
||||
description="Copying share link bundles...",
|
||||
):
|
||||
bundle = ShareLinkBundle.objects.get(pk=record["pk"])
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.core.management.base import CommandError
|
||||
from django.test import TestCase
|
||||
|
||||
from documents.management.commands.document_importer import Command
|
||||
from documents.management.commands.document_importer import _deserialize_record
|
||||
from documents.models import Document
|
||||
from documents.settings import EXPORTER_ARCHIVE_NAME
|
||||
from documents.settings import EXPORTER_FILE_NAME
|
||||
@@ -397,3 +398,307 @@ class TestCommandImport(
|
||||
|
||||
# There should be no error or warnings. Therefore the output should be empty.
|
||||
self.assertEqual(stdout_str, "")
|
||||
|
||||
def test_batch_size_argument_accepted(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A valid source directory with an empty manifest
|
||||
WHEN:
|
||||
- Import is called with --batch-size 100
|
||||
THEN:
|
||||
- No argument parsing error is raised
|
||||
"""
|
||||
manifest_file = self.dirs.scratch_dir / "manifest.json"
|
||||
manifest_file.write_text("[]")
|
||||
|
||||
try:
|
||||
call_command(
|
||||
"document_importer",
|
||||
"--no-progress-bar",
|
||||
"--batch-size",
|
||||
"100",
|
||||
str(self.dirs.scratch_dir),
|
||||
skip_checks=True,
|
||||
)
|
||||
except CommandError:
|
||||
pass # Expected: empty manifest or missing files, not an argument error
|
||||
except SystemExit as e:
|
||||
self.fail(f"--batch-size raised SystemExit (unrecognized argument?): {e}")
|
||||
|
||||
def test_m2m_relations_restored_after_data_only_import(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest with a Tag (pk=100) and a Document (pk=100) with
|
||||
tags: [100] in the fields
|
||||
WHEN:
|
||||
- Data-only import is performed
|
||||
THEN:
|
||||
- Document.objects.get(pk=100).tags.count() == 1
|
||||
- The tag's name is preserved correctly
|
||||
"""
|
||||
tag_record = {
|
||||
"model": "documents.tag",
|
||||
"pk": 100,
|
||||
"fields": {"name": "imported-tag"},
|
||||
}
|
||||
doc_record = {
|
||||
"model": "documents.document",
|
||||
"pk": 100,
|
||||
"fields": {
|
||||
"title": "Tagged Doc",
|
||||
"content": "test content",
|
||||
"checksum": "1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
|
||||
"filename": "0001000.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"modified": "2024-01-01T00:00:00Z",
|
||||
"added": "2024-01-01T00:00:00Z",
|
||||
"tags": [100],
|
||||
"correspondent": None,
|
||||
"document_type": None,
|
||||
"storage_path": None,
|
||||
},
|
||||
}
|
||||
|
||||
manifest_file = self.dirs.scratch_dir / "manifest.json"
|
||||
manifest_file.write_text(json.dumps([tag_record, doc_record]))
|
||||
|
||||
call_command(
|
||||
"document_importer",
|
||||
"--no-progress-bar",
|
||||
"--data-only",
|
||||
str(self.dirs.scratch_dir),
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
doc = Document.objects.get(pk=100)
|
||||
self.assertEqual(doc.tags.count(), 1)
|
||||
self.assertEqual(doc.tags.first().name, "imported-tag")
|
||||
|
||||
def test_mid_batch_flush_triggered_by_small_batch_size(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest with two records (Tag + Document)
|
||||
- --batch-size 1 so each record fills a batch immediately
|
||||
WHEN:
|
||||
- Import is performed
|
||||
THEN:
|
||||
- flush_model() fires mid-loop (before flush_all) and the import
|
||||
completes correctly with the M2M relation intact
|
||||
"""
|
||||
tag_record = {
|
||||
"model": "documents.tag",
|
||||
"pk": 200,
|
||||
"fields": {"name": "batch-flush-tag"},
|
||||
}
|
||||
doc_record = {
|
||||
"model": "documents.document",
|
||||
"pk": 200,
|
||||
"fields": {
|
||||
"title": "Batch Flush Doc",
|
||||
"content": "test",
|
||||
"checksum": "2093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab",
|
||||
"filename": "0002000.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"modified": "2024-01-01T00:00:00Z",
|
||||
"added": "2024-01-01T00:00:00Z",
|
||||
"tags": [200],
|
||||
"correspondent": None,
|
||||
"document_type": None,
|
||||
"storage_path": None,
|
||||
},
|
||||
}
|
||||
|
||||
manifest_file = self.dirs.scratch_dir / "manifest.json"
|
||||
manifest_file.write_text(json.dumps([tag_record, doc_record]))
|
||||
|
||||
call_command(
|
||||
"document_importer",
|
||||
"--no-progress-bar",
|
||||
"--data-only",
|
||||
"--batch-size",
|
||||
"1",
|
||||
str(self.dirs.scratch_dir),
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
doc = Document.objects.get(pk=200)
|
||||
self.assertEqual(doc.tags.count(), 1)
|
||||
self.assertEqual(doc.tags.first().name, "batch-flush-tag")
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestDeserializeRecord:
|
||||
def test_simple_model_no_relations(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest record for a Correspondent (no M2M fields)
|
||||
WHEN:
|
||||
- _deserialize_record is called
|
||||
THEN:
|
||||
- Returns the correct model class, a Correspondent instance with
|
||||
correct field values, and an empty m2m_data dict
|
||||
"""
|
||||
record = {
|
||||
"model": "documents.correspondent",
|
||||
"pk": 42,
|
||||
"fields": {
|
||||
"name": "ACME Corp",
|
||||
"match": "",
|
||||
"matching_algorithm": 1,
|
||||
"is_insensitive": False,
|
||||
"owner": None,
|
||||
},
|
||||
}
|
||||
model, instance, m2m_data = _deserialize_record(record)
|
||||
assert model.__name__ == "Correspondent"
|
||||
assert instance.pk == 42
|
||||
assert instance.name == "ACME Corp"
|
||||
assert m2m_data == {}
|
||||
|
||||
def test_fk_field_stored_on_attname(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest record for a Document with a FK to a Correspondent
|
||||
WHEN:
|
||||
- _deserialize_record is called
|
||||
THEN:
|
||||
- The FK integer is stored on field.attname (correspondent_id),
|
||||
not the descriptor attribute (correspondent)
|
||||
"""
|
||||
record = {
|
||||
"model": "documents.document",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"title": "Test Doc",
|
||||
"correspondent": 42,
|
||||
"content": "",
|
||||
"checksum": "abc123abc123abc123abc123abc123ab",
|
||||
"filename": "0000001.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
},
|
||||
}
|
||||
_, instance, _ = _deserialize_record(record)
|
||||
assert instance.correspondent_id == 42
|
||||
|
||||
def test_m2m_field_collected_in_m2m_data(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest record for a Document with a tags M2M list
|
||||
WHEN:
|
||||
- _deserialize_record is called
|
||||
THEN:
|
||||
- M2M PKs are returned in m2m_data under the field name
|
||||
"""
|
||||
record = {
|
||||
"model": "documents.document",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"title": "Test",
|
||||
"tags": [1, 3, 7],
|
||||
"content": "",
|
||||
"checksum": "abc123abc123abc123abc123abc123ab",
|
||||
"filename": "0000001.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
},
|
||||
}
|
||||
_, _, m2m_data = _deserialize_record(record)
|
||||
assert m2m_data["tags"] == [1, 3, 7]
|
||||
|
||||
def test_null_fk_stored_as_none(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest record with a nullable FK set to null
|
||||
WHEN:
|
||||
- _deserialize_record is called
|
||||
THEN:
|
||||
- The FK attname is None, not 0 or a string
|
||||
"""
|
||||
record = {
|
||||
"model": "documents.document",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"title": "Test",
|
||||
"correspondent": None,
|
||||
"content": "",
|
||||
"checksum": "def456def456def456def456def456de",
|
||||
"filename": "0000002.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
},
|
||||
}
|
||||
_, instance, _ = _deserialize_record(record)
|
||||
assert instance.correspondent_id is None
|
||||
|
||||
def test_unknown_model_raises_deserialization_error(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest record with a model label that does not exist
|
||||
WHEN:
|
||||
- _deserialize_record is called
|
||||
THEN:
|
||||
- DeserializationError is raised
|
||||
"""
|
||||
from django.core.serializers.base import DeserializationError
|
||||
|
||||
record = {"model": "documents.doesnotexist", "pk": 1, "fields": {}}
|
||||
with pytest.raises(DeserializationError):
|
||||
_deserialize_record(record)
|
||||
|
||||
def test_invalid_pk_raises_deserialization_error(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest record whose pk value cannot be coerced to the field type
|
||||
WHEN:
|
||||
- _deserialize_record is called
|
||||
THEN:
|
||||
- DeserializationError is raised mentioning the bad pk value
|
||||
"""
|
||||
from django.core.serializers.base import DeserializationError
|
||||
|
||||
record = {"model": "documents.correspondent", "pk": "not-an-int", "fields": {}}
|
||||
with pytest.raises(
|
||||
DeserializationError,
|
||||
match="Could not coerce pk=not-an-int",
|
||||
):
|
||||
_deserialize_record(record)
|
||||
|
||||
def test_invalid_scalar_field_value_raises_deserialization_error(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest record with a scalar field whose value cannot be coerced
|
||||
WHEN:
|
||||
- _deserialize_record is called
|
||||
THEN:
|
||||
- DeserializationError is raised mentioning the field and bad value
|
||||
"""
|
||||
from django.core.serializers.base import DeserializationError
|
||||
|
||||
record = {
|
||||
"model": "documents.correspondent",
|
||||
"pk": 1,
|
||||
"fields": {"matching_algorithm": "not-an-int"},
|
||||
}
|
||||
with pytest.raises(
|
||||
DeserializationError,
|
||||
match="Could not coerce matching_algorithm=",
|
||||
):
|
||||
_deserialize_record(record)
|
||||
|
||||
def test_unknown_field_name_raises_field_does_not_exist(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A manifest record with a field name that does not exist on the model
|
||||
WHEN:
|
||||
- _deserialize_record is called
|
||||
THEN:
|
||||
- FieldDoesNotExist is raised
|
||||
"""
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
|
||||
record = {
|
||||
"model": "documents.correspondent",
|
||||
"pk": 1,
|
||||
"fields": {"no_such_field_on_correspondent": "value"},
|
||||
}
|
||||
with pytest.raises(FieldDoesNotExist):
|
||||
_deserialize_record(record)
|
||||
+73
-29
@@ -15,6 +15,7 @@ from time import mktime
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
from typing import NamedTuple
|
||||
from unicodedata import normalize
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlparse
|
||||
@@ -176,6 +177,7 @@ from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
from documents.schema import generate_object_with_permissions_schema
|
||||
from documents.search import SearchHit
|
||||
from documents.serialisers import AcknowledgeTasksViewSerializer
|
||||
from documents.serialisers import BulkDownloadSerializer
|
||||
from documents.serialisers import BulkEditObjectsSerializer
|
||||
@@ -252,6 +254,7 @@ from paperless_mail.serialisers import MailRuleSerializer
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
|
||||
logger = logging.getLogger("paperless.api")
|
||||
|
||||
# Crossover point for intersect_and_order: below this count use a targeted
|
||||
@@ -294,6 +297,25 @@ def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
||||
return more_like_doc_id
|
||||
|
||||
|
||||
class SearchParams(NamedTuple):
|
||||
sort_field_name: str | None
|
||||
sort_reverse: bool
|
||||
use_tantivy_sort: bool
|
||||
page_num: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class SearchResultPage(NamedTuple):
|
||||
ordered_ids: list[int]
|
||||
hits: list[SearchHit]
|
||||
page_offset: int
|
||||
|
||||
|
||||
class ResolvedRequestDocs(NamedTuple):
|
||||
request_doc: Document
|
||||
root_doc: Document
|
||||
|
||||
|
||||
class IndexView(TemplateView):
|
||||
template_name = "index.html"
|
||||
|
||||
@@ -1234,7 +1256,7 @@ class DocumentViewSet(
|
||||
request: Request,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
) -> tuple[Document, Document] | HttpResponseForbidden:
|
||||
) -> ResolvedRequestDocs | HttpResponseForbidden:
|
||||
manager = Document.global_objects if include_deleted else Document.objects
|
||||
try:
|
||||
request_doc = manager.select_related(
|
||||
@@ -1254,7 +1276,7 @@ class DocumentViewSet(
|
||||
root_doc,
|
||||
):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
return request_doc, root_doc
|
||||
return ResolvedRequestDocs(request_doc=request_doc, root_doc=root_doc)
|
||||
|
||||
def file_response(self, pk, request, disposition):
|
||||
resolved = self._resolve_request_and_root_doc(
|
||||
@@ -1264,8 +1286,11 @@ class DocumentViewSet(
|
||||
)
|
||||
if isinstance(resolved, HttpResponseForbidden):
|
||||
return resolved
|
||||
request_doc, root_doc = resolved
|
||||
file_doc = self._get_effective_file_doc(request_doc, root_doc, request)
|
||||
file_doc = self._get_effective_file_doc(
|
||||
resolved.request_doc,
|
||||
resolved.root_doc,
|
||||
request,
|
||||
)
|
||||
return serve_file(
|
||||
doc=file_doc,
|
||||
use_archive=not self.original_requested(request)
|
||||
@@ -1308,11 +1333,14 @@ class DocumentViewSet(
|
||||
resolved = self._resolve_request_and_root_doc(pk, request)
|
||||
if isinstance(resolved, HttpResponseForbidden):
|
||||
return resolved
|
||||
request_doc, root_doc = resolved
|
||||
|
||||
# Choose the effective document (newest version by default,
|
||||
# or explicit via ?version=).
|
||||
doc = self._get_effective_file_doc(request_doc, root_doc, request)
|
||||
doc = self._get_effective_file_doc(
|
||||
resolved.request_doc,
|
||||
resolved.root_doc,
|
||||
request,
|
||||
)
|
||||
|
||||
document_cached_metadata = get_metadata_cache(doc.pk)
|
||||
|
||||
@@ -1517,10 +1545,13 @@ class DocumentViewSet(
|
||||
resolved = self._resolve_request_and_root_doc(pk, request)
|
||||
if isinstance(resolved, HttpResponseForbidden):
|
||||
return resolved
|
||||
request_doc, root_doc = resolved
|
||||
|
||||
try:
|
||||
file_doc = self._get_effective_file_doc(request_doc, root_doc, request)
|
||||
file_doc = self._get_effective_file_doc(
|
||||
resolved.request_doc,
|
||||
resolved.root_doc,
|
||||
request,
|
||||
)
|
||||
|
||||
return serve_file(
|
||||
doc=file_doc,
|
||||
@@ -1538,10 +1569,13 @@ class DocumentViewSet(
|
||||
resolved = self._resolve_request_and_root_doc(pk, request)
|
||||
if isinstance(resolved, HttpResponseForbidden):
|
||||
return resolved
|
||||
request_doc, root_doc = resolved
|
||||
|
||||
try:
|
||||
file_doc = self._get_effective_file_doc(request_doc, root_doc, request)
|
||||
file_doc = self._get_effective_file_doc(
|
||||
resolved.request_doc,
|
||||
resolved.root_doc,
|
||||
request,
|
||||
)
|
||||
handle = file_doc.thumbnail_file
|
||||
|
||||
return FileResponse(handle, content_type="image/webp")
|
||||
@@ -2222,7 +2256,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
from documents.search import TantivyRelevanceList
|
||||
from documents.search import get_backend
|
||||
|
||||
def parse_search_params() -> tuple[str | None, bool, bool, int, int]:
|
||||
def parse_search_params() -> SearchParams:
|
||||
"""Extract query string, search mode, and ordering from request."""
|
||||
active = self._get_active_search_params(request)
|
||||
if len(active) > 1:
|
||||
@@ -2254,7 +2288,13 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
self.paginator.get_page_size(request) or self.paginator.page_size
|
||||
)
|
||||
|
||||
return sort_field_name, sort_reverse, use_tantivy_sort, page_num, page_size
|
||||
return SearchParams(
|
||||
sort_field_name=sort_field_name,
|
||||
sort_reverse=sort_reverse,
|
||||
use_tantivy_sort=use_tantivy_sort,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
def intersect_and_order(
|
||||
all_ids: list[int],
|
||||
@@ -2286,7 +2326,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
backend: TantivyBackend,
|
||||
user: User | None,
|
||||
filtered_qs: QuerySet[Document],
|
||||
) -> tuple[list[int], list[SearchHit], int]:
|
||||
) -> SearchResultPage:
|
||||
"""Handle text/title/query search: IDs, ORM intersection, page highlights."""
|
||||
query_str, search_mode = _get_tantivy_query_and_mode(request.query_params)
|
||||
|
||||
@@ -2320,13 +2360,17 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
search_mode=search_mode,
|
||||
rank_start=page_offset + 1,
|
||||
)
|
||||
return ordered_ids, page_hits, page_offset
|
||||
return SearchResultPage(
|
||||
ordered_ids=ordered_ids,
|
||||
hits=page_hits,
|
||||
page_offset=page_offset,
|
||||
)
|
||||
|
||||
def run_more_like_this(
|
||||
backend: TantivyBackend,
|
||||
user: User | None,
|
||||
filtered_qs: QuerySet[Document],
|
||||
) -> tuple[list[int], list[SearchHit], int]:
|
||||
) -> SearchResultPage:
|
||||
"""Handle more_like_id search: permission check, IDs, stub hits."""
|
||||
more_like_doc_id = _get_more_like_id(request.query_params, user)
|
||||
|
||||
@@ -2343,7 +2387,11 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
SearchHit(id=doc_id, score=0.0, rank=rank, highlights={})
|
||||
for rank, doc_id in enumerate(page_ids, start=page_offset + 1)
|
||||
]
|
||||
return ordered_ids, page_hits, page_offset
|
||||
return SearchResultPage(
|
||||
ordered_ids=ordered_ids,
|
||||
hits=page_hits,
|
||||
page_offset=page_offset,
|
||||
)
|
||||
|
||||
try:
|
||||
sort_field_name, sort_reverse, use_tantivy_sort, page_num, page_size = (
|
||||
@@ -2355,19 +2403,15 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
user = None if request.user.is_superuser else request.user
|
||||
|
||||
if "more_like_id" in request.query_params:
|
||||
ordered_ids, page_hits, page_offset = run_more_like_this(
|
||||
backend,
|
||||
user,
|
||||
filtered_qs,
|
||||
)
|
||||
result = run_more_like_this(backend, user, filtered_qs)
|
||||
else:
|
||||
ordered_ids, page_hits, page_offset = run_text_search(
|
||||
backend,
|
||||
user,
|
||||
filtered_qs,
|
||||
)
|
||||
result = run_text_search(backend, user, filtered_qs)
|
||||
|
||||
rl = TantivyRelevanceList(ordered_ids, page_hits, page_offset)
|
||||
rl = TantivyRelevanceList(
|
||||
result.ordered_ids,
|
||||
result.hits,
|
||||
result.page_offset,
|
||||
)
|
||||
page = self.paginate_queryset(rl)
|
||||
|
||||
if page is not None:
|
||||
@@ -2383,12 +2427,12 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
# at scale (tens of thousands of matching documents).
|
||||
response.data["selection_data"] = (
|
||||
self._get_selection_data_for_queryset(
|
||||
filtered_qs.filter(pk__in=ordered_ids),
|
||||
filtered_qs.filter(pk__in=result.ordered_ids),
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
serializer = self.get_serializer(page_hits, many=True)
|
||||
serializer = self.get_serializer(result.hits, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
except NotFound:
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-05-04 21:35+0000\n"
|
||||
"POT-Creation-Date: 2026-05-05 01:01+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1352,7 +1352,7 @@ msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:463 documents/serialisers.py:815
|
||||
#: documents/serialisers.py:2681 documents/views.py:292 documents/views.py:2400
|
||||
#: documents/serialisers.py:2681 documents/views.py:295 documents/views.py:2444
|
||||
#: paperless_mail/serialisers.py:155
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
@@ -1393,7 +1393,7 @@ msgstr ""
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2767 documents/views.py:4277
|
||||
#: documents/serialisers.py:2767 documents/views.py:4321
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1661,32 +1661,32 @@ msgstr ""
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:285 documents/views.py:2397
|
||||
#: documents/views.py:288 documents/views.py:2441
|
||||
msgid "Invalid more_like_id"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1463
|
||||
#: documents/views.py:1491
|
||||
msgid "Invalid AI configuration."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2232 documents/views.py:2538
|
||||
#: documents/views.py:2266 documents/views.py:2582
|
||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4289
|
||||
#: documents/views.py:4333
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4335
|
||||
#: documents/views.py:4379
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4395
|
||||
#: documents/views.py:4439
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4405
|
||||
#: documents/views.py:4449
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
Reference in new issue
Block a user