Compare commits

...
4 Commits
Author SHA1 Message Date
GitHub Actions bb5100b3d8 Auto translate strings 2026-05-05 01:02:33 +00:00
Trenton H c3b353873b use a few named tuples and data classes instead of so much unpacking (#12709) 2026-05-04 18:00:48 -07:00
Trenton H 3adeda28b4 Upgrades uv to the 0.11.x branch (#12710) 2026-05-04 23:44:36 +00:00
Trenton HandClaude Sonnet 4.6 e822e72964 Feature: Further reduce document importer memory usage (#12707)
* Replaces loaddata with streaming bulk_create

Replaces call_command('loaddata') with a streaming implementation that
reads manifest records one at a time via ijson, accumulates per-model
batches up to --batch-size, and flushes via bulk_create.  This reduces
peak memory and no longer scales directly with the size of the import.

* fix(importer): avoid guardian lru_cache poisoning; include M2M through tables in check_constraints

clear_cache() inside the import transaction emptied Django's ContentType
manager cache while fixture PKs were live, causing downstream ContentType
lookups to repopulate guardian's separate @lru_cache(None) with
fixture-PK objects. After the TestCase transaction rolled back to
original PKs, guardian's lru_cache held stale fixture ContentType
objects, causing MixedContentTypeError in unrelated subsequent tests.

Remove clear_cache() since it was defending against a theoretical
stale-cache scenario that doesn't occur in a proper same-install restore.

Fix check_constraints() to explicitly include auto-created M2M through
tables (populated by .set() after bulk_create) alongside the model tables,
addressing the gap where join-table FK violations would have gone
undetected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Excludes the consumer and AnonymousUser from any models which might have a FK relation to it.  This prevents orphan things like UI setting, which have a relation to no existing user

* Splits into more sub functions for Sonar

* Improvements to the typing of the new functions

* Coverage for some error cases, and removes handling for pk only models.  No need to support these

* Final coverage gaps

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 16:36:05 -07:00
12 changed files with 703 additions and 169 deletions

No files matched your search

+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+10 -10
View File
@@ -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 ""