mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-29 07:14:56 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3e0cf0e21 | ||
|
|
5b63a7bc41 | ||
|
|
fb99b3defb | ||
|
|
e80ede4cb0 | ||
|
|
e0902c63d6 | ||
|
|
0506dcad05 | ||
|
|
91647eecb5 | ||
|
|
693a111c5a | ||
|
|
b19edd0b74 | ||
|
|
0e98a7f1ce | ||
|
|
14517062c4 | ||
|
|
0c0faf7f80 | ||
|
|
eda79603fe | ||
|
|
8f017942d9 | ||
|
|
f4e7a2e9e4 | ||
|
|
317e534aa0 | ||
|
|
3ba6d0325a | ||
|
|
318520a0f1 | ||
|
|
ea1f3653a9 | ||
|
|
423d4b9f2d |
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+48
@@ -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}" \
|
||||
"$@"
|
||||
@@ -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,
|
||||
)
|
||||
Executable
+24
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 "<x id="PH" equiv-text="newValues.title"/>" 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 "<x id="PH" equiv-text="this.document().title"/>"</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 "<x id="PH" equiv-text="this.document().title"/>" 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 "<x id="PH" equiv-text="this.document().title"/>" 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 "<x id="PH" equiv-text="this.document().title"/>" 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 "<x id="PH" equiv-text="this.document().title"/>" 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">
|
||||
|
||||
@@ -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'">
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,6 +2,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class DocumentClassifierSchema(BaseModel):
|
||||
"""Schema for document classification suggestions."""
|
||||
|
||||
title: str
|
||||
tags: list[str]
|
||||
correspondents: list[str]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user