Compare commits

...
Author SHA1 Message Date
stumpylog b3e0cf0e21 chore(profiling): confirm Stage 2 loop-resolution reduces query count and wall time at scale 2026-07-28 16:36:44 -07:00
stumpylog 5b63a7bc41 chore(profiling): record Stage 1 after-profile, confirm speedup and no query-count regression 2026-07-27 21:20:55 -07:00
stumpylog fb99b3defb profiling(stage1): re-capture baseline with realistic document ownership
Guardian permission subplans now actually execute (previously
'never executed' due to unowned seed documents short-circuiting the
owner_id IS NULL branch). best_seconds rose from 0.0251s to 31.4648s,
confirming the varchar-cast nested-loop-semi-join pathology is real
and now measured under realistic conditions.
2026-07-27 19:02:30 -07:00
stumpylog e80ede4cb0 fix(profiling): assign realistic document ownership so seed data exercises the guardian permission path 2026-07-27 17:25:48 -07:00
stumpylog e0902c63d6 chore(profiling): record Stage 1 baseline for get_objects_for_user_owner_aware 2026-07-27 16:03:15 -07:00
stumpylogandClaude Sonnet 5 0506dcad05 chore(profiling): add append_profiling_history function for persistent profiling records
Adds two new functions to harness.py:
- _current_git_ref(): Gets the current git short SHA
- append_profiling_history(): Appends JSON line to profiling/results/history.jsonl

This enables profiling numbers to persist across sessions/dates, with every run
appended to an immutable timeline rather than overwriting per-stage snapshots.

Also adds corresponding test to verify the function writes valid JSON lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:49:14 -07:00
stumpylog 91647eecb5 chore(profiling): add throwaway-Postgres helper and typed profiling harness
Adds standalone profiling tooling (never merged into dev/main): a
persistent, self-healing Postgres 18 container helper
(run_with_postgres.sh/stop_postgres.sh), a scale-profile dataset seeder
(seed.py) whose document counts and guardian permission-row ratios
mirror real bug reports (#13276, #13161), and a typed profiling harness
(harness.py) for timing/query-count comparisons and EXPLAIN ANALYZE
capture, gated by require_postgres() so it never silently runs on
SQLite.

seed.py samples distinct (subject, document) pairs up front rather than
drawing with replacement, since guardian's assign_perm() dedupes on the
(subject, object, permission) triple -- with-replacement sampling
against a small group pool collides heavily (birthday paradox) and
undercounts the target permission-row ratios otherwise.
2026-07-27 15:08:23 -07:00
GitHub Actions 693a111c5a Auto translate strings 2026-07-27 19:35:15 +00:00
Trenton HandGitHub b19edd0b74 Fix: dedupe permission-visible documents when combined with multi-tag ALL filtering (#13331) (#13345)
The permission filter OR'd three querysets together on top of a
queryset that could already carry two independent tags__id__all
joins, letting a document that matched more than one branch (e.g.
unowned + group-permissioned) come back twice. Replaced it with a
single id__in filter against the existing permitted_document_ids
helper, which is join-free and can't hit this.
2026-07-27 19:33:26 +00:00
Trenton HandGitHub 0e98a7f1ce Performance: Scope llm index updates to actually modified documents (#13322)
* Perf: scope bulk_update_documents' LLM index refresh to the edited document ids instead of the whole library

* Perf: select_related/prefetch_related for the scoped LLM index batch

* Perf: select_related/prefetch_related for the full LLM index rebuild path

* Fix: address Copilot review findings on LLM index scoping
2026-07-27 10:23:43 -07:00
GitHub Actions 14517062c4 Auto translate strings 2026-07-27 16:53:33 +00:00
shamoonandGitHub 0c0faf7f80 Fixhancement: pass LLM output language to chat if specified (#13340) 2026-07-27 16:51:31 +00:00
SandroandGitHub eda79603fe Documentation: fix PAPERLESS_AI_LLM_OUTPUT_LANGUAGE heading level (#13341) 2026-07-27 09:37:09 -07:00
shamoonandGitHub 8f017942d9 Chore: resolve npm provenance issues with chokidar and semver (#13323) 2026-07-26 16:25:49 -07:00
GitHub Actions f4e7a2e9e4 Auto translate strings 2026-07-26 22:38:16 +00:00
shamoonandGitHub 317e534aa0 Fix: ensure preview reload on live changes (#13321) 2026-07-26 22:36:03 +00:00
Trenton HandGitHub 3ba6d0325a Fix: clamp out-of-range MailRule.order and ApplicationConfiguration DPI/page fields before smallint migration (#13316) 2026-07-26 22:11:27 +00:00
Trenton HandGitHub 318520a0f1 Fix: add docstring to DocumentClassifierSchema for cleaner LLM tool description (#13315) 2026-07-26 22:00:06 +00:00
Trenton HandGitHub ea1f3653a9 Fix: guard build_document_node against stale FK on deleted correspondent/doc type (#13318) 2026-07-26 21:45:59 +00:00
shamoonandGitHub 423d4b9f2d Fix: prevent search filter loss when closing document with Escape key (#13317) 2026-07-26 14:17:26 -07:00
41 changed files with 1350 additions and 108 deletions
+1 -1
View File
@@ -2135,7 +2135,7 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
Defaults to None.
### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE}
#### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE}
: The language to use for AI suggestions (results may vary by LLM model). If not supplied, defaults to the user's UI language setting or None.
View File
+123
View File
@@ -0,0 +1,123 @@
# profiling/harness.py
from __future__ import annotations
import json
import subprocess
import time
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import Generic
from typing import TypeVar
import pytest
from django.db import connection
from django.test.utils import CaptureQueriesContext
if TYPE_CHECKING:
from collections.abc import Callable
from django.db.models import QuerySet
from profiling.seed import ScaleProfile
T = TypeVar("T")
def require_postgres() -> None:
if connection.vendor != "postgresql":
pytest.skip(
"Profiling requires PostgreSQL to reflect production query "
"planning; SQLite does not reproduce the varchar-cast planner "
"pathology this work fixes.",
)
@dataclass(frozen=True, slots=True)
class ProfileResult(Generic[T]):
best_seconds: float
all_seconds: tuple[float, ...]
query_count: int
result: T
def run_profile(fn: Callable[[], T], *, repeat: int = 5) -> ProfileResult[T]:
require_postgres()
all_seconds: list[float] = []
result: T | None = None
query_count = 0
for i in range(repeat):
with CaptureQueriesContext(connection) as ctx:
start = time.perf_counter()
result = fn()
all_seconds.append(time.perf_counter() - start)
if i == repeat - 1:
query_count = len(ctx.captured_queries)
assert result is not None # repeat >= 1 guarantees at least one assignment
return ProfileResult(
best_seconds=min(all_seconds),
all_seconds=tuple(all_seconds),
query_count=query_count,
result=result,
)
def capture_explain_analyze(queryset: QuerySet[Any]) -> str:
if connection.vendor != "postgresql":
raise RuntimeError("EXPLAIN ANALYZE capture is Postgres-only")
sql, params = queryset.query.sql_with_params()
with connection.cursor() as cur:
cur.execute(f"EXPLAIN ANALYZE {sql}", params)
return "\n".join(row[0] for row in cur.fetchall())
def _current_git_ref() -> str:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
return result.stdout.strip() or "unknown"
def append_profiling_history(
*,
stage: str,
scenario: str,
best_seconds: float,
query_count: int,
scale: ScaleProfile,
code_ref: str | None = None,
) -> None:
"""
Append one line to profiling/results/history.jsonl -- an append-only,
cross-session record of every profiling run. Unlike the per-stage
before/after snapshot JSON files (which the next run of the same stage
overwrites), this never gets clobbered, so a profiling effort picked
back up days later has a full timeline to resume from instead of only
the single most recent comparison.
``code_ref`` should identify the *production* code under test (a short
SHA on the feature branch, not this `profiling` branch) -- pass it
explicitly when known. Left as ``None``, it falls back to this repo's
current ``git rev-parse --short HEAD``, which is only meaningful if
you're actually running from a checkout where that reflects the code
being measured.
"""
results_dir = Path(__file__).parent / "results"
results_dir.mkdir(exist_ok=True)
entry = {
"timestamp": datetime.now(UTC).isoformat(),
"code_ref": code_ref or _current_git_ref(),
"stage": stage,
"scenario": scenario,
"scale": scale,
"best_seconds": best_seconds,
"query_count": query_count,
}
with (results_dir / "history.jsonl").open("a") as f:
f.write(json.dumps(entry) + "\n")
+18
View File
@@ -0,0 +1,18 @@
[tool.pytest]
ini_options.pythonpath = [ "../src", "..", "." ]
ini_options.markers = [
"""\
profiling: Profiling scripts comparing old vs new query patterns; require a throwaway PostgreSQL container, not run \
as part of the normal test suite\
""",
]
ini_options.DJANGO_SETTINGS_MODULE = "paperless.settings"
[tool.pytest_env]
# Standalone config: this directory must be independently runnable without
# the rest of the dev stack (Redis, etc) present -- only Postgres is required,
# via run_with_postgres.sh.
PAPERLESS_SECRET_KEY = "profiling-only-insecure-key"
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
+8
View File
@@ -0,0 +1,8 @@
{"timestamp": "2026-07-27T22:47:46.677504+00:00", "code_ref": "test-run", "stage": "self-check", "scenario": "test_append_profiling_history_writes_a_valid_json_line", "scale": "medium", "best_seconds": 0.001, "query_count": 0}
{"timestamp": "2026-07-27T22:49:03.762638+00:00", "code_ref": "test-run", "stage": "self-check", "scenario": "test_append_profiling_history_writes_a_valid_json_line", "scale": "medium", "best_seconds": 0.001, "query_count": 0}
{"timestamp": "2026-07-27T23:01:49.777269+00:00", "code_ref": "0506dcad0", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 0.025123266968876123, "query_count": 1}
{"timestamp": "2026-07-28T01:38:32.706519+00:00", "code_ref": "e80ede4cb", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 31.46481539506931, "query_count": 1}
{"timestamp": "2026-07-28T03:53:46.770669+00:00", "code_ref": "d4fa85237", "stage": "stage1", "scenario": "get_objects_for_user_owner_aware_document_baseline", "scale": "medium", "best_seconds": 31.671881378046237, "query_count": 1}
{"timestamp": "2026-07-28T04:02:41.942168+00:00", "code_ref": "d4fa85237", "stage": "stage1", "scenario": "permitted_document_ids_after_stage1", "scale": "medium", "best_seconds": 0.07956207206007093, "query_count": 1}
{"timestamp": "2026-07-28T23:30:28.602501+00:00", "code_ref": "86846255f", "stage": "stage2", "scenario": "per_row_checker_vs_resolved_set_old", "scale": "medium", "best_seconds": 3.4628250940004364, "query_count": 692}
{"timestamp": "2026-07-28T23:30:28.606987+00:00", "code_ref": "86846255f", "stage": "stage2", "scenario": "per_row_checker_vs_resolved_set_new", "scale": "medium", "best_seconds": 0.10436739504802972, "query_count": 1}
+9
View File
@@ -0,0 +1,9 @@
{
"permitted_document_ids": {
"best_seconds": 0.07956207206007093,
"query_count": 1
},
"baseline_best_seconds": 31.671881378046237,
"baseline_query_count": 1,
"speedup_x": 398.07763370131113
}
@@ -0,0 +1,58 @@
Index Scan Backward using documents_document_created_bedd0818 on documents_document v0 (cost=91.31..99.33 rows=1 width=2900) (actual time=68.340..77.517 rows=12024.00 loops=1)
Filter: ((deleted_at IS NULL) AND ((owner_id = 12) OR (owner_id IS NULL) OR (ANY (id = (hashed SubPlan 1).col1))))
Rows Removed by Filter: 7976
Index Searches: 1
Buffers: shared hit=74978
SubPlan 1
-> Unique (cost=91.04..91.05 rows=2 width=4) (actual time=64.698..66.622 rows=9136.00 loops=1)
Buffers: shared hit=73487
-> Sort (cost=91.04..91.05 rows=2 width=4) (actual time=64.697..65.202 rows=9262.00 loops=1)
Sort Key: ((u0.object_pk)::integer)
Sort Method: quicksort Memory: 385kB
Buffers: shared hit=73487
-> Append (cost=0.27..91.03 rows=2 width=4) (actual time=0.026..63.201 rows=9262.00 loops=1)
Buffers: shared hit=73487
-> Nested Loop (cost=0.27..12.79 rows=1 width=4) (actual time=0.026..1.834 rows=237.00 loops=1)
Join Filter: (u0.permission_id = u1.id)
Buffers: shared hit=696
-> Index Only Scan using guardian_userobjectpermi_user_id_permission_id_ob_b0b3d2fc_uniq on guardian_userobjectpermission u0 (cost=0.27..8.29 rows=1 width=520) (actual time=0.012..0.131 rows=237.00 loops=1)
Index Cond: (user_id = 12)
Heap Fetches: 237
Index Searches: 1
Buffers: shared hit=222
-> Seq Scan on auth_permission u1 (cost=0.00..4.49 rows=1 width=4) (actual time=0.006..0.006 rows=1.00 loops=237)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 67
Buffers: shared hit=474
-> Nested Loop (cost=4.73..78.23 rows=1 width=4) (actual time=1.119..60.528 rows=9025.00 loops=1)
Buffers: shared hit=72791
-> Nested Loop (cost=4.59..78.05 rows=1 width=524) (actual time=1.115..50.521 rows=9025.00 loops=1)
Buffers: shared hit=54741
-> Nested Loop (cost=4.44..73.62 rows=24 width=520) (actual time=1.109..13.226 rows=45387.00 loops=1)
Buffers: shared hit=329
-> Seq Scan on auth_permission u4 (cost=0.00..4.49 rows=1 width=4) (actual time=0.008..0.017 rows=1.00 loops=1)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 165
Buffers: shared hit=2
-> Bitmap Heap Scan on guardian_groupobjectpermission u0_1 (cost=4.44..68.93 rows=20 width=524) (actual time=1.100..8.095 rows=45387.00 loops=1)
Recheck Cond: (permission_id = u4.id)
Heap Blocks: exact=290
Buffers: shared hit=327
-> Bitmap Index Scan on guardian_groupobjectpermission_permission_id_36572738 (cost=0.00..4.43 rows=20 width=0) (actual time=1.052..1.053 rows=45387.00 loops=1)
Index Cond: (permission_id = u4.id)
Index Searches: 1
Buffers: shared hit=37
-> Index Only Scan using auth_user_groups_user_id_group_id_94350c0c_uniq on auth_user_groups u2 (cost=0.15..0.18 rows=1 width=4) (actual time=0.001..0.001 rows=0.20 loops=45387)
Index Cond: ((user_id = 12) AND (group_id = u0_1.group_id))
Heap Fetches: 9025
Index Searches: 45387
Buffers: shared hit=54412
-> Index Only Scan using auth_group_pkey on auth_group u1_1 (cost=0.14..0.17 rows=1 width=4) (actual time=0.001..0.001 rows=1.00 loops=9025)
Index Cond: (id = u0_1.group_id)
Heap Fetches: 9025
Index Searches: 9025
Buffers: shared hit=18050
Planning:
Buffers: shared hit=2
Planning Time: 1.089 ms
Execution Time: 78.020 ms
+6
View File
@@ -0,0 +1,6 @@
{
"get_objects_for_user_owner_aware_document": {
"best_seconds": 31.671881378046237,
"query_count": 1
}
}
@@ -0,0 +1,68 @@
Sort (cost=3790.80..3790.82 rows=9 width=2900) (actual time=39120.166..39120.831 rows=12024.00 loops=1)
Sort Key: documents_document.created DESC
Sort Method: quicksort Memory: 3230kB
Buffers: shared hit=8598270
-> Seq Scan on documents_document (cost=2550.72..3790.65 rows=9 width=2900) (actual time=0.009..39114.720 rows=12024.00 loops=1)
Filter: ((deleted_at IS NULL) AND ((owner_id = 2) OR (owner_id IS NULL) OR (ANY (id = (hashed SubPlan 1).col1)) OR (ANY (id = (hashed SubPlan 2).col1))))
Rows Removed by Filter: 7976
Buffers: shared hit=8598270
SubPlan 1
-> Nested Loop Semi Join (cost=0.00..1245.63 rows=1 width=8) (actual time=4.254..969.033 rows=237.00 loops=1)
Join Filter: ((((v0.object_pk)::bigint)::character varying)::text = (((u0.id)::bigint)::character varying)::text)
Rows Removed by Join Filter: 2539276
Buffers: shared hit=214835
-> Nested Loop (cost=0.00..23.30 rows=1 width=516) (actual time=0.031..2.425 rows=237.00 loops=1)
Join Filter: (v0.permission_id = v2.id)
Buffers: shared hit=490
-> Seq Scan on guardian_userobjectpermission v0 (cost=0.00..18.80 rows=1 width=520) (actual time=0.021..0.424 rows=237.00 loops=1)
Filter: (user_id = 2)
Rows Removed by Filter: 2120
Buffers: shared hit=16
-> Seq Scan on auth_permission v2 (cost=0.00..4.49 rows=1 width=4) (actual time=0.006..0.006 rows=1.00 loops=237)
Filter: ((content_type_id = 17) AND ((codename)::text = 'view_document'::text))
Rows Removed by Filter: 67
Buffers: shared hit=474
-> Seq Scan on documents_document u0 (cost=0.00..1221.96 rows=12 width=4) (actual time=0.002..1.917 rows=10715.24 loops=237)
Filter: (deleted_at IS NULL)
Buffers: shared hit=214345
SubPlan 2
-> Nested Loop Semi Join (cost=4.73..1305.09 rows=1 width=8) (actual time=6.729..38127.912 rows=9025.00 loops=1)
Join Filter: ((((v0_1.object_pk)::bigint)::character varying)::text = (((u0_2.id)::bigint)::character varying)::text)
Rows Removed by Join Filter: 98955406
Buffers: shared hit=8382237
-> Nested Loop Semi Join (cost=4.73..82.77 rows=1 width=516) (actual time=1.109..137.661 rows=9025.00 loops=1)
Buffers: shared hit=145515
-> Nested Loop (cost=4.44..73.62 rows=24 width=520) (actual time=1.090..18.002 rows=45387.00 loops=1)
Buffers: shared hit=329
-> Seq Scan on auth_permission v2_1 (cost=0.00..4.49 rows=1 width=4) (actual time=0.011..0.023 rows=1.00 loops=1)
Filter: (((codename)::text = 'view_document'::text) AND (content_type_id = 17))
Rows Removed by Filter: 165
Buffers: shared hit=2
-> Bitmap Heap Scan on guardian_groupobjectpermission v0_1 (cost=4.44..68.93 rows=20 width=524) (actual time=1.077..10.777 rows=45387.00 loops=1)
Recheck Cond: (permission_id = v2_1.id)
Heap Blocks: exact=290
Buffers: shared hit=327
-> Bitmap Index Scan on guardian_groupobjectpermission_permission_id_36572738 (cost=0.00..4.43 rows=20 width=0) (actual time=1.026..1.026 rows=45387.00 loops=1)
Index Cond: (permission_id = v2_1.id)
Index Searches: 1
Buffers: shared hit=37
-> Nested Loop (cost=0.30..0.37 rows=1 width=8) (actual time=0.002..0.002 rows=0.20 loops=45387)
Join Filter: (u0_1.id = u1.group_id)
Buffers: shared hit=145186
-> Index Only Scan using auth_group_pkey on auth_group u0_1 (cost=0.14..0.17 rows=1 width=4) (actual time=0.001..0.001 rows=1.00 loops=45387)
Index Cond: (id = v0_1.group_id)
Heap Fetches: 45387
Index Searches: 45387
Buffers: shared hit=90774
-> Index Only Scan using auth_user_groups_user_id_group_id_94350c0c_uniq on auth_user_groups u1 (cost=0.15..0.18 rows=1 width=4) (actual time=0.001..0.001 rows=0.20 loops=45387)
Index Cond: ((user_id = 2) AND (group_id = v0_1.group_id))
Heap Fetches: 9025
Index Searches: 45387
Buffers: shared hit=54412
-> Seq Scan on documents_document u0_2 (cost=0.00..1221.96 rows=12 width=4) (actual time=0.002..1.956 rows=10965.59 loops=9025)
Filter: (deleted_at IS NULL)
Buffers: shared hit=8236722
Planning:
Buffers: shared hit=3
Planning Time: 1.119 ms
Execution Time: 39121.312 ms
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# profiling/run_with_postgres.sh
#
# Starts a PostgreSQL 18 container (matching
# docker/compose/docker-compose.postgres.yml's image) if one isn't already
# running, waits for it to accept connections, force-terminates any
# leftover connections from a previous run that crashed or was killed, then
# runs the given command against it with the right PAPERLESS_DB* env vars
# set. The container is intentionally left running afterward -- see
# stop_postgres.sh to tear it down explicitly once you're done profiling
# for the session.
set -euo pipefail
CONTAINER_NAME="pngx-profiling-pg"
HOST_PORT="55432"
if ! docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker run --rm -d \
--name "$CONTAINER_NAME" \
-e POSTGRES_DB=paperless \
-e POSTGRES_USER=paperless \
-e POSTGRES_PASSWORD=paperless \
-p "${HOST_PORT}:5432" \
docker.io/library/postgres:18 >/dev/null
echo "Started profiling Postgres container '$CONTAINER_NAME' on port $HOST_PORT (left running -- see profiling/stop_postgres.sh)." >&2
fi
until docker exec "$CONTAINER_NAME" pg_isready -U paperless >/dev/null 2>&1; do
sleep 1
done
# Reap any connections left over from a previous run that crashed, was
# killed, or timed out mid-query -- without this, a persistent container
# can accumulate stuck backends that block later test-database
# create/drop operations indefinitely.
docker exec "$CONTAINER_NAME" psql -U paperless -d paperless -v ON_ERROR_STOP=0 -c "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname IN ('paperless', 'test_paperless')
AND pid <> pg_backend_pid();
" >/dev/null 2>&1 || true
PAPERLESS_DBHOST=localhost \
PAPERLESS_DBNAME=paperless \
PAPERLESS_DBUSER=paperless \
PAPERLESS_DBPASS=paperless \
PAPERLESS_DBPORT="${HOST_PORT}" \
"$@"
+199
View File
@@ -0,0 +1,199 @@
# profiling/seed.py
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import Literal
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from guardian.shortcuts import assign_perm
from documents.models import Document
from documents.tests.factories import CorrespondentFactory
from documents.tests.factories import DocumentFactory
from documents.tests.factories import DocumentTypeFactory
from documents.tests.factories import StoragePathFactory
from documents.tests.factories import TagFactory
if TYPE_CHECKING:
from documents.models import Correspondent
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
ScaleProfile = Literal["medium", "large"]
# Fraction of seeded documents given a real owner; the remainder stays
# owner=None, mirroring a real install's mix of owned and legacy/imported
# unowned documents.
_OWNED_FRACTION = 0.9
class _ScaleCounts:
__slots__ = (
"correspondents",
"document_types",
"documents",
"groups",
"storage_paths",
"tags",
"users",
)
def __init__(
self,
*,
documents: int,
tags: int,
correspondents: int,
document_types: int,
storage_paths: int,
users: int,
groups: int,
) -> None:
self.documents = documents
self.tags = tags
self.correspondents = correspondents
self.document_types = document_types
self.storage_paths = storage_paths
self.users = users
self.groups = groups
_SCALE_PROFILES: dict[ScaleProfile, _ScaleCounts] = {
"medium": _ScaleCounts(
documents=20_000,
tags=100,
correspondents=300,
document_types=50,
storage_paths=20,
users=10,
groups=5,
),
"large": _ScaleCounts(
documents=360_000,
tags=1_000,
correspondents=5_000,
document_types=300,
storage_paths=50,
users=25,
groups=10,
),
}
# Ratios measured from a real install (discussion #13276): 1,414 user-perm
# rows / 27,232 group-perm rows over 12,000 documents.
_USER_PERM_ROWS_PER_DOC = 1_414 / 12_000
_GROUP_PERM_ROWS_PER_DOC = 27_232 / 12_000
def _sample_distinct_pairs(
rng: random.Random,
n_subjects: int,
n_objects: int,
count: int,
) -> set[tuple[int, int]]:
"""
Rejection-sample `count` distinct (subject_index, object_index) pairs.
`count` is always well under `n_subjects * n_objects` for every scale
profile, so this terminates quickly.
"""
if count > n_subjects * n_objects:
msg = (
f"cannot sample {count} distinct pairs from only "
f"{n_subjects * n_objects} possible (subject, object) slots"
)
raise ValueError(msg)
pairs: set[tuple[int, int]] = set()
while len(pairs) < count:
pairs.add((rng.randrange(n_subjects), rng.randrange(n_objects)))
return pairs
@dataclass(frozen=True, slots=True)
class SeededData:
users: tuple[User, ...]
groups: tuple[Group, ...]
documents: tuple[Document, ...]
tags: tuple[Tag, ...]
correspondents: tuple[Correspondent, ...]
document_types: tuple[DocumentType, ...]
storage_paths: tuple[StoragePath, ...]
def seed_permission_dataset(
scale: ScaleProfile = "medium",
*,
seed: int = 1337,
) -> SeededData:
"""
Build a dataset whose document count and guardian-permission-row ratios
mirror real bug reports, so profiling results are representative of
actual large installs rather than an arbitrary small fixture.
"""
counts = _SCALE_PROFILES[scale]
rng = random.Random(seed)
users = tuple(
User.objects.create_user(username=f"profile_user_{i}")
for i in range(counts.users)
)
groups = tuple(
Group.objects.create(name=f"profile_group_{i}") for i in range(counts.groups)
)
for user in users:
user.groups.add(rng.choice(groups))
documents = list(DocumentFactory.create_batch(counts.documents))
# Assign realistic ownership -- without this, every document stays
# owner=None and the "owned or unowned" visibility clause is trivially
# true for every row, so no guardian permission check ever needs to run
# (see the amendment note above this code block).
owned_documents = []
for document in documents:
if rng.random() < _OWNED_FRACTION:
document.owner = rng.choice(users)
owned_documents.append(document)
Document.objects.bulk_update(owned_documents, ["owner"], batch_size=2_000)
documents = tuple(documents)
tags = tuple(TagFactory.create_batch(counts.tags))
correspondents = tuple(CorrespondentFactory.create_batch(counts.correspondents))
document_types = tuple(DocumentTypeFactory.create_batch(counts.document_types))
storage_paths = tuple(StoragePathFactory.create_batch(counts.storage_paths))
n_user_perms = round(counts.documents * _USER_PERM_ROWS_PER_DOC)
n_group_perms = round(counts.documents * _GROUP_PERM_ROWS_PER_DOC)
# Grant permissions only on OWNED documents -- a grant on an already-
# unowned document is a no-op for visibility (unowned documents are
# visible to everyone regardless), so restricting the pool here is what
# makes each assign_perm() call actually matter for someone, and is
# what exercises the guardian-permission-join code path this profiling
# effort exists to measure.
for user_idx, owned_idx in _sample_distinct_pairs(
rng,
len(users),
len(owned_documents),
n_user_perms,
):
assign_perm("view_document", users[user_idx], owned_documents[owned_idx])
for group_idx, owned_idx in _sample_distinct_pairs(
rng,
len(groups),
len(owned_documents),
n_group_perms,
):
assign_perm("view_document", groups[group_idx], owned_documents[owned_idx])
return SeededData(
users=users,
groups=groups,
documents=documents,
tags=tags,
correspondents=correspondents,
document_types=document_types,
storage_paths=storage_paths,
)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# profiling/stop_postgres.sh
#
# Explicit, manual teardown of the profiling Postgres container. Not called
# automatically by run_with_postgres.sh -- run this yourself once you're
# done with a profiling session, so a seeded `large`-scale dataset can be
# reused across several script runs first. Terminates any active backends
# before stopping, so a stuck query can't stall `docker stop` waiting for
# a graceful Postgres shutdown that never comes.
set -euo pipefail
CONTAINER_NAME="pngx-profiling-pg"
if docker inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
docker exec "$CONTAINER_NAME" psql -U paperless -d paperless -v ON_ERROR_STOP=0 -c "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid();
" >/dev/null 2>&1 || true
docker stop "$CONTAINER_NAME" >/dev/null
echo "Stopped and removed profiling Postgres container."
else
echo "No profiling Postgres container was running."
fi
+74
View File
@@ -0,0 +1,74 @@
# profiling/test_harness_self_check.py
from __future__ import annotations
import json
from pathlib import Path
import pytest
from profiling.harness import append_profiling_history
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
@pytest.mark.profiling
@pytest.mark.django_db
def test_seed_medium_scale_produces_expected_counts() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
assert len(data.documents) == 20_000
assert len(data.tags) == 100
assert len(data.correspondents) == 300
assert len(data.document_types) == 50
assert len(data.storage_paths) == 20
from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission
user_perm_count = UserObjectPermission.objects.count()
group_perm_count = GroupObjectPermission.objects.count()
# within 5% of the ratio-derived target -- exact counts depend on random
# sampling without replacement, see seed.py
assert 2_242 <= user_perm_count <= 2_478
assert 43_111 <= group_perm_count <= 47_649
@pytest.mark.profiling
@pytest.mark.django_db
def test_run_profile_reports_query_count_and_timing() -> None:
require_postgres()
def trivial() -> list[int]:
from documents.models import Document
return list(Document.objects.values_list("id", flat=True)[:1])
result = run_profile(trivial, repeat=3)
assert result.best_seconds >= 0
assert len(result.all_seconds) == 3
assert result.query_count >= 1
def test_append_profiling_history_writes_a_valid_json_line() -> None:
history_path = Path(__file__).parent / "results" / "history.jsonl"
before_lines = (
history_path.read_text().splitlines() if history_path.exists() else []
)
append_profiling_history(
stage="self-check",
scenario="test_append_profiling_history_writes_a_valid_json_line",
best_seconds=0.001,
query_count=0,
scale="medium",
code_ref="test-run",
)
after_lines = history_path.read_text().splitlines()
assert len(after_lines) == len(before_lines) + 1
entry = json.loads(after_lines[-1])
assert entry["stage"] == "self-check"
assert entry["code_ref"] == "test-run"
assert entry["scale"] == "medium"
assert "timestamp" in entry
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from documents.models import Document
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import permitted_document_ids
from profiling.harness import append_profiling_history
from profiling.harness import capture_explain_analyze
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
RESULTS_DIR = Path(__file__).parent / "results"
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_get_objects_for_user_owner_aware_document_baseline() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
def call() -> list[int]:
return list(
get_objects_for_user_owner_aware(
user,
"documents.view_document",
Document,
).values_list("id", flat=True),
)
profile = run_profile(call, repeat=3)
plan = capture_explain_analyze(
get_objects_for_user_owner_aware(user, "documents.view_document", Document),
)
RESULTS_DIR.mkdir(exist_ok=True)
(RESULTS_DIR / "stage1_baseline.json").write_text(
json.dumps(
{
"get_objects_for_user_owner_aware_document": {
"best_seconds": profile.best_seconds,
"query_count": profile.query_count,
},
},
indent=2,
),
)
(RESULTS_DIR / "stage1_baseline_explain.txt").write_text(plan)
append_profiling_history(
stage="stage1",
scenario="get_objects_for_user_owner_aware_document_baseline",
best_seconds=profile.best_seconds,
query_count=profile.query_count,
scale="medium",
)
print(f"\nBASELINE best={profile.best_seconds:.4f}s queries={profile.query_count}") # noqa: T201
print(plan) # noqa: T201
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_permitted_document_ids_after_stage1() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
def call():
return list(
Document.objects.filter(id__in=permitted_document_ids(user)).values_list(
"id",
flat=True,
),
)
profile = run_profile(call, repeat=3)
plan = capture_explain_analyze(
Document.objects.filter(id__in=permitted_document_ids(user)),
)
baseline = json.loads((RESULTS_DIR / "stage1_baseline.json").read_text())
baseline_seconds = baseline["get_objects_for_user_owner_aware_document"][
"best_seconds"
]
baseline_queries = baseline["get_objects_for_user_owner_aware_document"][
"query_count"
]
(RESULTS_DIR / "stage1_after.json").write_text(
json.dumps(
{
"permitted_document_ids": {
"best_seconds": profile.best_seconds,
"query_count": profile.query_count,
},
"baseline_best_seconds": baseline_seconds,
"baseline_query_count": baseline_queries,
"speedup_x": baseline_seconds / profile.best_seconds
if profile.best_seconds
else None,
},
indent=2,
),
)
(RESULTS_DIR / "stage1_after_explain.txt").write_text(plan)
append_profiling_history(
stage="stage1",
scenario="permitted_document_ids_after_stage1",
best_seconds=profile.best_seconds,
query_count=profile.query_count,
scale="medium",
)
print(f"\nBEFORE best={baseline_seconds:.4f}s queries={baseline_queries}") # noqa: T201
print(f"AFTER best={profile.best_seconds:.4f}s queries={profile.query_count}") # noqa: T201
# Hard gate: must not be a regression, and should be meaningfully faster
# at this scale (the whole point of this stage).
assert profile.best_seconds < baseline_seconds
assert profile.query_count <= baseline_queries
+64
View File
@@ -0,0 +1,64 @@
# profiling/test_stage2_document_loops.py
from __future__ import annotations
import pytest
from documents.permissions import has_perms_owner_aware
from documents.permissions import permitted_document_ids
from profiling.harness import append_profiling_history
from profiling.harness import require_postgres
from profiling.harness import run_profile
from profiling.seed import seed_permission_dataset
@pytest.mark.profiling
@pytest.mark.django_db
def test_profile_per_row_checker_vs_resolved_set_for_bulk_operation() -> None:
require_postgres()
data = seed_permission_dataset(scale="medium")
user = data.users[0]
# simulate a bulk operation over 500 documents the user can actually see
# (bulk-edit/share/trash all operate on a user's own selection) -- built
# from confirmed-visible documents so all() can't short-circuit unfairly
# in either old_style or new_style, see amendment note above
permitted_ids = set(permitted_document_ids(user))
batch = [doc for doc in data.documents if doc.pk in permitted_ids][:500]
assert len(batch) == 500, (
f"expected at least 500 visible documents to build a fair batch, "
f"got {len(batch)} -- scale profile or seed ratios may need adjusting"
)
def old_style():
return all(has_perms_owner_aware(user, "view_document", doc) for doc in batch)
def new_style():
permitted = set(permitted_document_ids(user))
return all(doc.pk in permitted for doc in batch)
old_profile = run_profile(old_style, repeat=3)
new_profile = run_profile(new_style, repeat=3)
append_profiling_history(
stage="stage2",
scenario="per_row_checker_vs_resolved_set_old",
best_seconds=old_profile.best_seconds,
query_count=old_profile.query_count,
scale="medium",
)
append_profiling_history(
stage="stage2",
scenario="per_row_checker_vs_resolved_set_new",
best_seconds=new_profile.best_seconds,
query_count=new_profile.query_count,
scale="medium",
)
print( # noqa: T201
f"\nOLD (per-row checker): best={old_profile.best_seconds:.4f}s queries={old_profile.query_count}",
)
print( # noqa: T201
f"NEW (resolved set): best={new_profile.best_seconds:.4f}s queries={new_profile.query_count}",
)
assert new_profile.query_count < old_profile.query_count
assert new_profile.best_seconds < old_profile.best_seconds
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test'
import { expect, test, type WebSocketRoute } from '@playwright/test'
import path from 'node:path'
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
@@ -95,3 +95,60 @@ test('should support quick filters', async ({ page }) => {
.click()
await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/)
})
test('should finish reloading the preview after a remote document update', async ({
page,
}) => {
let resolveStatusSocket: (socket: WebSocketRoute) => void
const statusSocketReady = new Promise<WebSocketRoute>((resolve) => {
resolveStatusSocket = resolve
})
await page.routeWebSocket(/\/ws\/status\/$/, (socket) => {
resolveStatusSocket(socket)
})
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
let previewRequestCount = 0
page.on('request', (request) => {
if (request.url().includes('/api/documents/175/preview/')) {
previewRequestCount++
}
})
await page.goto('/documents/175/details')
await page.locator('pngx-document-detail').waitFor()
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
/\w+/
)
const previewWasLoaded = await page.evaluate(() => {
const detail = document.querySelector('pngx-document-detail')
const component = (window as any).ng.getComponent(detail)
component.pdfPreviewLoaded({ numPages: 1 })
return component.previewLoaded()
})
expect(previewWasLoaded).toBe(true)
const previewRequestsBeforeReload = previewRequestCount
const statusSocket = await statusSocketReady
const documentReloaded = page.waitForResponse(
(response) =>
response.url().includes('/api/documents/175/?full_perms=true') &&
response.request().method() === 'GET'
)
statusSocket.send(
JSON.stringify({
type: 'document_updated',
data: {
document_id: 175,
modified: '2026-07-26T20:00:00Z',
},
})
)
await documentReloaded
await expect(
page.getByText('Document reloaded with latest changes.').first()
).toBeVisible()
await expect
.poll(() => previewRequestCount)
.toBeGreaterThan(previewRequestsBeforeReload + 1)
})
+49 -49
View File
@@ -1277,7 +1277,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1787</context>
<context context-type="linenumber">1791</context>
</context-group>
</trans-unit>
<trans-unit id="1577733187050997705" datatype="html">
@@ -2184,7 +2184,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">653</context>
<context context-type="linenumber">657</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
@@ -3087,11 +3087,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1405</context>
<context context-type="linenumber">1409</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1788</context>
<context context-type="linenumber">1792</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3688,7 +3688,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1358</context>
<context context-type="linenumber">1362</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3800,7 +3800,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1841</context>
<context context-type="linenumber">1845</context>
</context-group>
</trans-unit>
<trans-unit id="6661109599266152398" datatype="html">
@@ -3811,7 +3811,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1842</context>
<context context-type="linenumber">1846</context>
</context-group>
</trans-unit>
<trans-unit id="5162686434580248853" datatype="html">
@@ -3822,7 +3822,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1843</context>
<context context-type="linenumber">1847</context>
</context-group>
</trans-unit>
<trans-unit id="8157388568390631653" datatype="html">
@@ -5724,7 +5724,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1362</context>
<context context-type="linenumber">1366</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7966,88 +7966,88 @@
<source>Enter Password</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">512</context>
<context context-type="linenumber">513</context>
</context-group>
</trans-unit>
<trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">423</context>
<context context-type="linenumber">424</context>
</context-group>
</trans-unit>
<trans-unit id="2218903673684131427" datatype="html">
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">525,527</context>
<context context-type="linenumber">526,528</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">982,984</context>
<context context-type="linenumber">986,988</context>
</context-group>
</trans-unit>
<trans-unit id="6357361810318120957" datatype="html">
<source>Document was updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">648</context>
<context context-type="linenumber">652</context>
</context-group>
</trans-unit>
<trans-unit id="5154064822428631306" datatype="html">
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">649</context>
<context context-type="linenumber">653</context>
</context-group>
</trans-unit>
<trans-unit id="8462497568316256794" datatype="html">
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">650</context>
<context context-type="linenumber">654</context>
</context-group>
</trans-unit>
<trans-unit id="7967484035994732534" datatype="html">
<source>Reload</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">652</context>
<context context-type="linenumber">656</context>
</context-group>
</trans-unit>
<trans-unit id="2907037627372942104" datatype="html">
<source>Document reloaded with latest changes.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">708</context>
<context context-type="linenumber">712</context>
</context-group>
</trans-unit>
<trans-unit id="6435639868943916539" datatype="html">
<source>Document reloaded.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">719</context>
<context context-type="linenumber">723</context>
</context-group>
</trans-unit>
<trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">821</context>
<context context-type="linenumber">825</context>
</context-group>
</trans-unit>
<trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">831</context>
<context context-type="linenumber">835</context>
</context-group>
</trans-unit>
<trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">839</context>
<context context-type="linenumber">843</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
@@ -8058,67 +8058,67 @@
<source>Save document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">846</context>
<context context-type="linenumber">850</context>
</context-group>
</trans-unit>
<trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">855</context>
<context context-type="linenumber">859</context>
</context-group>
</trans-unit>
<trans-unit id="7427704425579737895" datatype="html">
<source>Error retrieving version content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">965</context>
<context context-type="linenumber">969</context>
</context-group>
</trans-unit>
<trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1026</context>
<context context-type="linenumber">1030</context>
</context-group>
</trans-unit>
<trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1238</context>
<context context-type="linenumber">1242</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1265</context>
<context context-type="linenumber">1269</context>
</context-group>
</trans-unit>
<trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document().title"/>&quot;</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1271</context>
<context context-type="linenumber">1275</context>
</context-group>
</trans-unit>
<trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1326</context>
<context context-type="linenumber">1330</context>
</context-group>
</trans-unit>
<trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document().title"/>&quot; to the trash?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1359</context>
<context context-type="linenumber">1363</context>
</context-group>
</trans-unit>
<trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1360</context>
<context context-type="linenumber">1364</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8129,14 +8129,14 @@
<source>Error deleting document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1381</context>
<context context-type="linenumber">1385</context>
</context-group>
</trans-unit>
<trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1401</context>
<context context-type="linenumber">1405</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8147,102 +8147,102 @@
<source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1402</context>
<context context-type="linenumber">1406</context>
</context-group>
</trans-unit>
<trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1403</context>
<context context-type="linenumber">1407</context>
</context-group>
</trans-unit>
<trans-unit id="4700389117298802932" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1413</context>
<context context-type="linenumber">1417</context>
</context-group>
</trans-unit>
<trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1424</context>
<context context-type="linenumber">1428</context>
</context-group>
</trans-unit>
<trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1487</context>
<context context-type="linenumber">1491</context>
</context-group>
</trans-unit>
<trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1565</context>
<context context-type="linenumber">1569</context>
</context-group>
</trans-unit>
<trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1808</context>
<context context-type="linenumber">1812</context>
</context-group>
</trans-unit>
<trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1820</context>
<context context-type="linenumber">1824</context>
</context-group>
</trans-unit>
<trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1831</context>
<context context-type="linenumber">1835</context>
</context-group>
</trans-unit>
<trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1865</context>
<context context-type="linenumber">1869</context>
</context-group>
</trans-unit>
<trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1879</context>
<context context-type="linenumber">1883</context>
</context-group>
</trans-unit>
<trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1925</context>
<context context-type="linenumber">1929</context>
</context-group>
</trans-unit>
<trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1938</context>
<context context-type="linenumber">1942</context>
</context-group>
</trans-unit>
<trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2008</context>
<context context-type="linenumber">2012</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2014</context>
<context context-type="linenumber">2018</context>
</context-group>
</trans-unit>
<trans-unit id="4958946940233632319" datatype="html">
+3
View File
@@ -2,6 +2,9 @@ packages:
- "."
minimumReleaseAge: 10080
trustPolicy: no-downgrade
trustPolicyExclude:
- "chokidar@4.0.3"
- "semver@6.3.1 || 5.7.2"
allowBuilds:
"@parcel/watcher": true
canvas: true
@@ -283,6 +283,22 @@ describe('PngxPdfViewerComponent', () => {
expect(mockViewer.currentPageNumber).toBe(1)
})
it('reloads when the source revision changes', () => {
const resetSpy = jest.spyOn(component as any, 'resetViewerState')
const loadSpy = jest
.spyOn(component as any, 'loadDocument')
.mockImplementation(() => {})
component.src = 'test.pdf'
component.sourceRevision = 1
component.ngOnChanges({
sourceRevision: new SimpleChange(0, 1, false),
})
expect(resetSpy).toHaveBeenCalled()
expect(loadSpy).toHaveBeenCalled()
})
it('applies viewer state after view init when already loaded', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true
@@ -43,6 +43,7 @@ export class PngxPdfViewerComponent
private readonly document = inject<Document>(DOCUMENT)
@Input() src!: string
@Input() sourceRevision = 0
@Input() password?: string
@Input() page?: number
@Output() pageChange = new EventEmitter<number>()
@@ -93,7 +94,7 @@ export class PngxPdfViewerComponent
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['src'] || changes['password']) {
if (changes['src'] || changes['sourceRevision'] || changes['password']) {
this.resetViewerState()
if (this.src) {
this.loadDocument()
@@ -472,6 +472,7 @@
<div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer
[src]="pdfSource()"
[sourceRevision]="previewRevision()"
[password]="pdfPassword()"
[renderMode]="PdfRenderMode.All"
[page]="previewCurrentPage()" (pageChange)="previewCurrentPage.set($event)"
@@ -1602,6 +1602,7 @@ describe('DocumentDetailComponent', () => {
expect(openDoc.__changedFields).toEqual([])
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
expect(saveSpy).toHaveBeenCalled()
expect(component.previewRevision()).toBe(1)
})
it('should ignore incoming update for a different document id', () => {
@@ -249,6 +249,7 @@ export class DocumentDetailComponent
titleSubject: Subject<string> = new Subject()
readonly previewUrl = signal<string>(undefined)
readonly pdfSource = signal<string>(undefined)
readonly previewRevision = signal(0)
readonly pdfPassword = signal<string>(undefined)
readonly thumbUrl = signal<string>(undefined)
readonly previewText = signal<string>(undefined)
@@ -609,6 +610,9 @@ export class DocumentDetailComponent
.subscribe()
}
this.updateComponent(useDoc)
if (forceRemote) {
this.previewRevision.update((revision) => revision + 1)
}
this.titleSubject
.pipe(
debounceTime(1000),
@@ -25,7 +25,7 @@
<input #textFilterInput class="form-control form-control-sm" type="text"
[disabled]="textFilterModifierIsNull"
[(ngModel)]="textFilter"
(keyup)="textFilterKeyup($event)"
(keydown)="textFilterKeydown($event)"
[ngbTypeahead]="searchAutoComplete"
(selectItem)="itemSelected($event)"
[readonly]="textFilterTarget === 'fulltext-morelike'">
@@ -2180,17 +2180,17 @@ describe('FilterEditorComponent', () => {
it('should support Enter / Esc key on text field', () => {
component.textFilterInput.nativeElement.value = 'foo'
component.textFilterInput.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Enter' })
new KeyboardEvent('keydown', { key: 'Enter' })
)
expect(component.textFilter).toEqual('foo')
component.textFilterInput.nativeElement.value = 'foo bar'
component.textFilterInput.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Escape' })
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(component.textFilter).toEqual('')
const blurSpy = jest.spyOn(component.textFilterInput.nativeElement, 'blur')
component.textFilterInput.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Escape' })
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(blurSpy).toHaveBeenCalled()
})
@@ -1312,7 +1312,7 @@ export class FilterEditorComponent
}
}
textFilterKeyup(event: KeyboardEvent) {
textFilterKeydown(event: KeyboardEvent) {
if (event.key == 'Enter') {
const filterString = (
this.textFilterInput.nativeElement as HTMLInputElement
+27
View File
@@ -37,6 +37,7 @@ from drf_spectacular.utils import extend_schema_field
from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model
from rest_framework import serializers
from rest_framework.filters import BaseFilterBackend
from rest_framework.filters import OrderingFilter
from rest_framework_guardian.filters import ObjectPermissionsFilter
@@ -50,6 +51,7 @@ from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_document_ids
if TYPE_CHECKING:
from collections.abc import Callable
@@ -1038,6 +1040,31 @@ class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
return objects_with_perms | objects_owned | objects_unowned
class DocumentPermissionsFilter(BaseFilterBackend):
"""
A filter backend limiting Document results to those the requesting user
owns, are unowned, or has explicit (user- or group-level) view
permission on.
Unlike ``ObjectOwnedOrGrantedPermissionsFilter``, this does not build an
``objects_with_perms | objects_owned | objects_unowned`` union of
querysets derived from the same base queryset. When that base queryset
already carries independent joins on a multi-valued relation (e.g. two
separate joins from ``tags__id__all`` filtering on two tags), each
OR-ed branch can end up pairing those joins' aliases differently,
letting more than one row out of the join's cross product satisfy the
combined WHERE -- returning the same document more than once. Filtering
via a single ``id__in`` against ``permitted_document_ids`` (a plain
subquery, not a join) sidesteps that entirely and is also cheaper than
guardian's join-based permission check.
"""
def filter_queryset(self, request, queryset, view):
if request.user.is_superuser:
return queryset
return queryset.filter(id__in=permitted_document_ids(request.user))
class ObjectOwnedPermissionsFilter(ObjectPermissionsFilter):
"""
A filter backend that limits results to those where the requesting user
+3 -3
View File
@@ -163,7 +163,7 @@ def set_permissions_for_object(
)
def _permitted_document_ids(user):
def permitted_document_ids(user):
"""
Return a queryset of document IDs the user may view, limited to non-deleted
documents. This intentionally avoids ``get_objects_for_user`` to keep the
@@ -220,7 +220,7 @@ def get_document_count_filter_for_user(user, related_name: str = "documents"):
# Superuser: no permission filtering needed
return Q(**{f"{related_name}__deleted_at__isnull": True})
permitted_ids = _permitted_document_ids(user)
permitted_ids = permitted_document_ids(user)
return Q(**{f"{related_name}__id__in": permitted_ids})
@@ -311,7 +311,7 @@ def annotate_document_count_for_related_queryset(
queryset,
through_model=through_model,
related_object_field=related_object_field,
document_ids=_permitted_document_ids(user),
document_ids=permitted_document_ids(user),
target_field=target_field,
)
+2
View File
@@ -311,6 +311,7 @@ def sanity_check(*, raise_on_error: bool = True) -> str:
def bulk_update_documents(document_ids) -> None:
from documents.search import get_backend
document_ids = list(document_ids)
documents = Document.objects.filter(id__in=document_ids)
for doc in documents:
@@ -331,6 +332,7 @@ def bulk_update_documents(document_ids) -> None:
if ai_config.llm_index_enabled:
update_llm_index(
rebuild=False,
document_ids=document_ids,
)
+88
View File
@@ -14,6 +14,7 @@ from unittest import mock
import celery
from dateutil import parser
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.core import mail
@@ -47,6 +48,8 @@ from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.signals.handlers import run_workflows
from documents.tests.factories import DocumentFactory
from documents.tests.factories import TagFactory
from documents.tests.utils import ConsumeTaskMixin
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import read_streaming_response
@@ -1212,6 +1215,91 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
[u1_doc1.id],
)
def test_document_owned_and_group_shared_not_duplicated_when_filtering_by_tags(
self,
) -> None:
"""
GIVEN:
- A document owned by a user and also shared with a group the user belongs to
WHEN:
- The user filters documents by more than one tag (tags__id__all)
THEN:
- The document is returned exactly once, not once per permission path
(regression test for https://github.com/paperless-ngx/paperless-ngx/issues/13331)
"""
user = User.objects.create_user("user1")
user.user_permissions.add(*Permission.objects.filter(codename="view_document"))
group = Group.objects.create(name="group1")
user.groups.add(group)
tag1 = TagFactory()
tag2 = TagFactory()
doc = DocumentFactory(title="shared", owner=user)
doc.tags.add(tag1, tag2)
assign_perm("view_document", group, doc)
self.client.force_authenticate(user=user)
response = self.client.get(
f"/api/documents/?tags__id__all={tag1.id},{tag2.id}",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["id"], doc.id)
def test_document_permission_filter_excludes_unrelated_documents(self) -> None:
"""
GIVEN:
- A document owned by one user, with no permission granted to another user
WHEN:
- The unrelated user requests the document list
THEN:
- The document does not appear in their results
"""
owner = User.objects.create_user("owner1")
stranger = User.objects.create_user("stranger1")
stranger.user_permissions.add(
*Permission.objects.filter(codename="view_document"),
)
DocumentFactory(title="private", owner=owner)
self.client.force_authenticate(user=stranger)
response = self.client.get("/api/documents/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 0)
def test_document_permission_filter_only_visible_to_group_members(self) -> None:
"""
GIVEN:
- A document shared with a group via object permissions
WHEN:
- A group member and a non-member both request the document list
THEN:
- Only the group member sees the document
"""
owner = User.objects.create_user("owner2")
member = User.objects.create_user("member1")
non_member = User.objects.create_user("nonmember1")
for u in (member, non_member):
u.user_permissions.add(*Permission.objects.filter(codename="view_document"))
group = Group.objects.create(name="group2")
member.groups.add(group)
doc = DocumentFactory(title="shared2", owner=owner)
assign_perm("view_document", group, doc)
self.client.force_authenticate(user=member)
response = self.client.get("/api/documents/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["id"], doc.id)
self.client.force_authenticate(user=non_member)
response = self.client.get("/api/documents/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 0)
def test_pagination_results(self) -> None:
"""
GIVEN:
+6 -2
View File
@@ -401,6 +401,10 @@ class TestAIIndex(DirectoriesMixin, TestCase):
"documents.tasks.update_llm_index",
) as update_llm_index,
):
tasks.bulk_update_documents([doc.pk for doc in docs])
doc_ids = [doc.pk for doc in docs]
tasks.bulk_update_documents(doc_ids)
self.assertEqual(update_document_in_llm_index.apply_async.call_count, 0)
update_llm_index.assert_called_once()
update_llm_index.assert_called_once_with(
rebuild=False,
document_ids=doc_ids,
)
+31
View File
@@ -625,6 +625,37 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/event-stream")
mock_stream_chat.assert_called_once_with(
query_str="question",
documents=[self.document],
output_language=None,
)
@patch("documents.views.stream_chat_with_documents")
@patch("documents.views.get_objects_for_user_owner_aware")
@override_settings(AI_ENABLED=True)
def test_post_uses_user_display_language(
self,
mock_get_objects,
mock_stream_chat,
) -> None:
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
self.grant_view_document_permission()
mock_get_objects.return_value = [self.document]
mock_stream_chat.return_value = iter([b"data"])
response = self.client.post(
self.ENDPOINT,
data='{"q": "question"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
mock_stream_chat.assert_called_once_with(
query_str="question",
documents=[self.document],
output_language="de-de",
)
@patch("documents.views.stream_chat_with_documents")
@override_settings(AI_ENABLED=True)
+24 -12
View File
@@ -133,6 +133,7 @@ from documents.file_handling import format_filename
from documents.filters import CorrespondentFilterSet
from documents.filters import CustomFieldFilterSet
from documents.filters import DocumentFilterSet
from documents.filters import DocumentPermissionsFilter
from documents.filters import DocumentsOrderingFilter
from documents.filters import DocumentTypeFilterSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
@@ -653,6 +654,20 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
update_document_parent_tags(tag, new_parent)
def _get_llm_output_language(ai_config: AIConfig, request) -> str | None:
output_language = ai_config.llm_output_language
if (
not output_language
and hasattr(request.user, "ui_settings")
and isinstance(
request.user.ui_settings.settings,
dict,
)
):
output_language = request.user.ui_settings.settings.get("language")
return output_language
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
class DocumentTypeViewSet(
PermissionsAwareDocumentCountMixin,
@@ -972,7 +987,7 @@ class DocumentViewSet(
DjangoFilterBackend,
SearchFilter,
DocumentsOrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
DocumentPermissionsFilter,
)
filterset_class = DocumentFilterSet
search_fields = ("title", "correspondent__name", "effective_content")
@@ -1514,16 +1529,7 @@ class DocumentViewSet(
if not ai_config.ai_enabled:
return HttpResponseBadRequest("AI is required for this feature")
output_language = ai_config.llm_output_language
if (
not output_language
and hasattr(request.user, "ui_settings")
and isinstance(
request.user.ui_settings.settings,
dict,
)
):
output_language = request.user.ui_settings.settings.get("language") or None
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
llm_cache_backend = (
f"{ai_config.llm_backend}:{output_language}"
if output_language
@@ -2265,8 +2271,14 @@ class ChatStreamingView(GenericAPIView[Any]):
Document,
)
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
response = StreamingHttpResponse(
stream_chat_with_documents(query_str=question, documents=documents),
stream_chat_with_documents(
query_str=question,
documents=documents,
output_language=output_language,
),
content_type="text/event-stream",
)
return response
+20 -20
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-25 07:12+0000\n"
"POT-Creation-Date: 2026-07-27 19:34+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr ""
#: documents/filters.py:470
#: documents/filters.py:472
msgid "Value must be valid JSON."
msgstr ""
#: documents/filters.py:489
#: documents/filters.py:491
msgid "Invalid custom field query expression"
msgstr ""
#: documents/filters.py:499
#: documents/filters.py:501
msgid "Invalid expression list. Must be nonempty."
msgstr ""
#: documents/filters.py:520
#: documents/filters.py:522
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:534
#: documents/filters.py:536
msgid "Maximum number of query conditions exceeded."
msgstr ""
#: documents/filters.py:598
#: documents/filters.py:600
msgid "{name!r} is not a valid custom field."
msgstr ""
#: documents/filters.py:635
#: documents/filters.py:637
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:750 documents/models.py:136
#: documents/filters.py:752 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1067
#: documents/filters.py:1094
msgid "Custom field not found"
msgstr ""
@@ -1352,7 +1352,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:522 documents/serialisers.py:874
#: documents/serialisers.py:2763 documents/views.py:298 documents/views.py:2541
#: documents/serialisers.py:2763 documents/views.py:299 documents/views.py:2553
#: 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:2849 documents/views.py:4500
#: documents/serialisers.py:2849 documents/views.py:4512
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1661,36 +1661,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:291 documents/views.py:2538
#: documents/views.py:292 documents/views.py:2550
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1556
#: documents/views.py:1562
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1565
#: documents/views.py:1571
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2363 documents/views.py:2684
#: documents/views.py:2375 documents/views.py:2696
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4512
#: documents/views.py:4524
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4558
#: documents/views.py:4570
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4619
#: documents/views.py:4631
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4629
#: documents/views.py:4641
msgid "The share link bundle is unavailable."
msgstr ""
@@ -5,12 +5,30 @@ from django.db import migrations
from django.db import models
def clamp_applicationconfiguration_integer_fields(apps, schema_editor):
# Clamp barcode_dpi, barcode_max_pages, image_dpi and pages because of
# PositiveIntegerField --> PositiveSmallIntegerField
ApplicationConfiguration = apps.get_model("paperless", "ApplicationConfiguration")
ApplicationConfiguration.objects.filter(barcode_dpi__gt=32767).update(
barcode_dpi=32767,
)
ApplicationConfiguration.objects.filter(barcode_max_pages__gt=32767).update(
barcode_max_pages=32767,
)
ApplicationConfiguration.objects.filter(image_dpi__gt=32767).update(image_dpi=32767)
ApplicationConfiguration.objects.filter(pages__gt=32767).update(pages=32767)
class Migration(migrations.Migration):
dependencies = [
("paperless", "0006_applicationconfiguration_barcode_tag_split"),
]
operations = [
migrations.RunPython(
clamp_applicationconfiguration_integer_fields,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name="applicationconfiguration",
name="barcode_dpi",
+2
View File
@@ -2,6 +2,8 @@ from pydantic import BaseModel
class DocumentClassifierSchema(BaseModel):
"""Schema for document classification suggestions."""
title: str
tags: list[str]
correspondents: list[str]
+27 -4
View File
@@ -28,11 +28,22 @@ CHAT_PROMPT_TMPL = (
"---------------------\n"
"Using only the context above, answer the query. "
"Do not use prior knowledge.\n"
"{output_language_line}"
"Query: {query_str}\n"
"Answer:"
)
def _build_chat_prompt(output_language: str | None) -> str:
output_language_line = (
f"Respond in {output_language}.\n" if output_language is not None else ""
)
return CHAT_PROMPT_TMPL.replace(
"{output_language_line}",
output_language_line,
)
def _build_document_reference(
document: Document,
title: str | None = None,
@@ -79,15 +90,27 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
)
def stream_chat_with_documents(query_str: str, documents: list[Document]):
def stream_chat_with_documents(
query_str: str,
documents: list[Document],
output_language: str | None = None,
):
try:
yield from _stream_chat_with_documents(query_str, documents)
yield from _stream_chat_with_documents(
query_str,
documents,
output_language=output_language,
)
except Exception as e:
logger.exception("Failed to stream document chat response: %s", e)
yield CHAT_ERROR_MESSAGE
def _stream_chat_with_documents(query_str: str, documents: list[Document]):
def _stream_chat_with_documents(
query_str: str,
documents: list[Document],
output_language: str | None = None,
):
if not documents:
yield CHAT_NO_CONTENT_MESSAGE
return
@@ -125,7 +148,7 @@ def _stream_chat_with_documents(query_str: str, documents: list[Document]):
references = _get_document_references(documents, top_nodes)
prompt_template = PromptTemplate(template=CHAT_PROMPT_TMPL)
prompt_template = PromptTemplate(template=_build_chat_prompt(output_language))
response_synthesizer = get_response_synthesizer(
llm=client.llm,
prompt_helper=get_rag_prompt_helper(
+37 -10
View File
@@ -5,6 +5,7 @@ from datetime import timedelta
from typing import TYPE_CHECKING
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from filelock import FileLock
from filelock import ReadWriteLock
@@ -167,6 +168,19 @@ def write_store(embed_model_name: str | None = None):
yield store
def _safe_related_name(document: Document, field: str) -> str | None:
"""
Returns the ``name`` of a related object (correspondent, document_type,
storage_path), or None if the FK is unset or points at a row that has
since been deleted (e.g. concurrently with this call).
"""
try:
related = getattr(document, field)
except ObjectDoesNotExist:
return None
return related.name if related else None
def build_document_node(
document: Document,
*,
@@ -180,14 +194,10 @@ def build_document_node(
"document_id": str(document.id),
"title": document.title,
"tags": [t.name for t in document.tags.all()],
"correspondent": document.correspondent.name
if document.correspondent
else None,
"document_type": document.document_type.name
if document.document_type
else None,
"correspondent": _safe_related_name(document, "correspondent"),
"document_type": _safe_related_name(document, "document_type"),
"filename": document.filename,
"storage_path": document.storage_path.name if document.storage_path else None,
"storage_path": _safe_related_name(document, "storage_path"),
"archive_serial_number": document.archive_serial_number,
"created": document.created.isoformat() if document.created else None,
"added": document.added.isoformat() if document.added else None,
@@ -318,8 +328,16 @@ def update_llm_index(
*,
iter_wrapper: IterWrapper[Document] = identity,
rebuild=False,
document_ids: Iterable[int] | None = None,
) -> str:
"""Rebuild or incrementally update the LLM index."""
"""Rebuild or incrementally update the LLM index.
``document_ids``, when given, scopes an incremental update to just those
documents instead of scanning the whole library -- callers that already
know which documents changed (e.g. a bulk edit) should pass this to avoid
an O(library size) scan per call. Ignored whenever a rebuild actually
happens, since a rebuild always covers the whole library regardless.
"""
with write_store() as store:
try:
with _exclude_readers():
@@ -335,7 +353,11 @@ def update_llm_index(
"LLM index migration requires re-embedding; forcing rebuild.",
)
rebuild = True
documents = Document.objects.all()
documents = Document.objects.select_related(
"correspondent",
"document_type",
"storage_path",
).prefetch_related("tags")
no_documents = not documents.exists()
# Fast exit before touching config: nothing to index and no existing index.
@@ -369,9 +391,14 @@ def update_llm_index(
store.add(nodes)
msg = "LLM index rebuilt successfully."
else:
scoped_documents = (
documents.filter(id__in=document_ids)
if document_ids is not None
else documents
)
existing = store.get_modified_times()
changed = 0
for document in iter_wrapper(documents):
for document in iter_wrapper(scoped_documents):
doc_id = str(document.id)
if existing.get(doc_id) == document.modified.isoformat():
continue
@@ -8,7 +8,9 @@ from django.test import override_settings
from django.utils import timezone
from llama_index.core.schema import MetadataMode
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.signals import document_consumption_finished
from documents.signals import document_updated
@@ -95,6 +97,38 @@ def test_build_document_node_structured_fields_in_metadata(
assert "modified" in node.metadata
@pytest.mark.django_db
def test_build_document_node_survives_concurrently_deleted_correspondent(
real_document: Document,
) -> None:
"""Regression test for #13314.
If a document's correspondent (or document type) is deleted after the
in-memory Document instance was loaded but before build_document_node
resolves the relation, accessing the FK must not raise -- it should
behave like an unset FK and produce None in the metadata instead of
aborting the whole indexing pass.
"""
correspondent = Correspondent.objects.create(name="Stale Correspondent")
document_type = DocumentType.objects.create(name="Stale Type")
real_document.correspondent = correspondent
real_document.document_type = document_type
real_document.save()
# Re-fetch to get an instance whose correspondent/document_type relations
# are unresolved (not yet cached), mirroring a task that loaded the
# document before the concurrent deletion below.
stale_document = Document.objects.get(pk=real_document.pk)
correspondent.delete()
document_type.delete()
nodes = indexing.build_document_node(stale_document)
assert len(nodes) > 0
assert nodes[0].metadata["correspondent"] is None
assert nodes[0].metadata["document_type"] is None
@pytest.mark.django_db
def test_build_document_node_excludes_document_id_from_llm_context(
real_document: Document,
@@ -163,6 +197,8 @@ def test_update_llm_index(
mock_queryset = MagicMock()
mock_queryset.exists.return_value = True
mock_queryset.__iter__.return_value = iter([real_document])
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = mock_queryset
mock_all.return_value = mock_queryset
build_document_node.return_value = []
indexing.update_llm_index(rebuild=True)
@@ -182,6 +218,8 @@ def test_update_llm_index_rebuilds_on_model_name_change(
mock_queryset = MagicMock()
mock_queryset.exists.return_value = True
mock_queryset.__iter__.return_value = iter([real_document])
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = mock_queryset
mock_all.return_value = mock_queryset
with patch(
"paperless_ai.indexing.get_configured_model_name",
@@ -194,6 +232,8 @@ def test_update_llm_index_rebuilds_on_model_name_change(
mock_queryset = MagicMock()
mock_queryset.exists.return_value = True
mock_queryset.__iter__.return_value = iter([real_document])
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = mock_queryset
mock_all.return_value = mock_queryset
with patch(
"paperless_ai.indexing.get_configured_model_name",
@@ -224,6 +264,8 @@ def test_update_llm_index_partial_update(
mock_queryset = MagicMock()
mock_queryset.exists.return_value = True
mock_queryset.__iter__.return_value = iter([real_document, doc2])
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.prefetch_related.return_value = mock_queryset
mock_all.return_value = mock_queryset
indexing.update_llm_index(rebuild=True)
@@ -252,6 +294,22 @@ def test_update_llm_index_partial_update(
assert store.table_exists(), (
"Expected the vector store table to exist after incremental update"
)
before = store.get_modified_times()
# A further edit, scoped via document_ids to just doc3 -- doc2 must be
# left exactly as it was, proving document_ids restricts the scan
# instead of falling back to the whole library.
doc3.modified = timezone.now()
doc3.save()
result = indexing.update_llm_index(rebuild=False, document_ids=[doc3.pk])
assert result == "LLM index updated successfully."
with indexing.get_vector_store() as store:
after = store.get_modified_times()
assert after[str(doc3.pk)] == doc3.modified.isoformat()
assert after[str(doc2.pk)] == before[str(doc2.pk)]
@pytest.mark.django_db
+21
View File
@@ -12,6 +12,7 @@ from paperless_ai import chat
from paperless_ai import indexing
from paperless_ai.chat import CHAT_ERROR_MESSAGE
from paperless_ai.chat import CHAT_METADATA_DELIMITER
from paperless_ai.chat import _build_chat_prompt
from paperless_ai.chat import stream_chat_with_documents
@@ -59,6 +60,26 @@ def assert_chat_output(
}
@pytest.mark.parametrize(
("output_language", "expected_language_line"),
[
(None, ""),
("de-de", "Respond in de-de.\n"),
],
)
def test_build_chat_prompt(
output_language,
expected_language_line,
) -> None:
prompt = _build_chat_prompt(output_language)
assert "{output_language_line}" not in prompt
assert (
prompt.split("Do not use prior knowledge.\n", maxsplit=1)[1]
== f"{expected_language_line}Query: {{query_str}}\nAnswer:"
)
@pytest.mark.django_db
def test_stream_chat_with_one_document_retrieval(
mock_document,
@@ -10,6 +10,24 @@ def clamp_mailrule_maximum_age(apps, schema_editor):
MailRule.objects.filter(maximum_age__gt=32767).update(maximum_age=32767)
def clamp_mailrule_order(apps, schema_editor):
# The order field of MailRule is only used for relative sorting
# (account.rules.order_by("order")), so out-of-range values must be
# renumbered by rank rather than clamped to a single value, which
# would collapse distinct rules to the same order.
MailRule = apps.get_model("paperless_mail", "MailRule")
if (
MailRule.objects.filter(order__gt=32767).exists()
or MailRule.objects.filter(
order__lt=-32768,
).exists()
): # pragma: no cover
for index, rule_id in enumerate(
MailRule.objects.order_by("order", "pk").values_list("pk", flat=True),
):
MailRule.objects.filter(pk=rule_id).update(order=index)
class Migration(migrations.Migration):
# The data update must commit before PostgreSQL rewrites the table, otherwise
# pending foreign-key trigger events can block the subsequent ALTER TABLE.
@@ -135,6 +153,10 @@ class Migration(migrations.Migration):
verbose_name="maximum age",
),
),
migrations.RunPython(
clamp_mailrule_order,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name="mailrule",
name="order",