mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-30 15:45:58 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed27ca9222 | ||
|
|
b57eb949cc | ||
|
|
e4bc8d721c | ||
|
|
f549e1818f |
@@ -129,8 +129,8 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
run: cd src-ui && pnpm run lint
|
run: cd src-ui && pnpm run lint
|
||||||
unit-tests:
|
unit-tests:
|
||||||
@@ -168,8 +168,8 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run Jest unit tests
|
- name: Run Jest unit tests
|
||||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
@@ -223,18 +223,15 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm install --no-frozen-lockfile
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run Playwright E2E tests
|
- name: Run Playwright E2E tests
|
||||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
bundle-analysis:
|
frontend-build:
|
||||||
name: Bundle Analysis
|
name: Frontend Build
|
||||||
needs: [changes, unit-tests, e2e-tests]
|
needs: [changes, unit-tests, e2e-tests]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment: bundle-analysis
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
@@ -260,21 +257,19 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Build and analyze
|
- name: Build
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
run: cd src-ui && pnpm run build --configuration=production
|
run: cd src-ui && pnpm run build --configuration=production
|
||||||
gate:
|
gate:
|
||||||
name: Frontend CI Gate
|
name: Frontend CI Gate
|
||||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
|
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Check gate
|
- name: Check gate
|
||||||
env:
|
env:
|
||||||
BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
|
BUILD_RESULT: ${{ needs['frontend-build'].result }}
|
||||||
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
||||||
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
||||||
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
||||||
@@ -306,8 +301,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
|
if [[ "${BUILD_RESULT}" != "success" ]]; then
|
||||||
echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
|
echo "::error::Frontend build job result: ${BUILD_RESULT}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ jobs:
|
|||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
run: cd src-ui && pnpm install
|
|
||||||
- name: Re-link Angular cli
|
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
|
||||||
- name: Generate frontend translation strings
|
- name: Generate frontend translation strings
|
||||||
run: |
|
run: |
|
||||||
cd src-ui
|
cd src-ui
|
||||||
|
|||||||
@@ -2135,7 +2135,7 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
|
|||||||
|
|
||||||
Defaults to None.
|
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.
|
: 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
# 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")
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{"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}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"permitted_document_ids": {
|
|
||||||
"best_seconds": 0.07956207206007093,
|
|
||||||
"query_count": 1
|
|
||||||
},
|
|
||||||
"baseline_best_seconds": 31.671881378046237,
|
|
||||||
"baseline_query_count": 1,
|
|
||||||
"speedup_x": 398.07763370131113
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"get_objects_for_user_owner_aware_document": {
|
|
||||||
"best_seconds": 31.671881378046237,
|
|
||||||
"query_count": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/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}" \
|
|
||||||
"$@"
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# 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,
|
|
||||||
)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 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
|
|
||||||
+12
-9
@@ -56,13 +56,13 @@
|
|||||||
},
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-builders/custom-webpack:browser",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"customWebpackConfig": {
|
"outputPath": {
|
||||||
"path": "./extra-webpack.config.ts"
|
"base": "dist/paperless-ui",
|
||||||
|
"browser": ""
|
||||||
},
|
},
|
||||||
"outputPath": "dist/paperless-ui",
|
"browser": "src/main.ts",
|
||||||
"main": "src/main.ts",
|
|
||||||
"outputHashing": "none",
|
"outputHashing": "none",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
@@ -97,6 +97,7 @@
|
|||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"file-saver",
|
"file-saver",
|
||||||
|
"mime-names",
|
||||||
"utif"
|
"utif"
|
||||||
],
|
],
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
@@ -117,11 +118,13 @@
|
|||||||
"with": "src/environments/environment.prod.ts"
|
"with": "src/environments/environment.prod.ts"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputPath": "../src/documents/static/frontend/",
|
"outputPath": {
|
||||||
|
"base": "../src/documents/static/frontend/",
|
||||||
|
"browser": ""
|
||||||
|
},
|
||||||
"optimization": true,
|
"optimization": true,
|
||||||
"outputHashing": "none",
|
"outputHashing": "none",
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"namedChunks": false,
|
|
||||||
"extractLicenses": true,
|
"extractLicenses": true,
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
@@ -145,7 +148,7 @@
|
|||||||
"defaultConfiguration": ""
|
"defaultConfiguration": ""
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-builders/custom-webpack:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build:en-US"
|
"buildTarget": "paperless-ui:build:en-US"
|
||||||
},
|
},
|
||||||
@@ -156,7 +159,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
"builder": "@angular/build:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build"
|
"buildTarget": "paperless-ui:build"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import {
|
|
||||||
CustomWebpackBrowserSchema,
|
|
||||||
TargetOptions,
|
|
||||||
} from '@angular-builders/custom-webpack'
|
|
||||||
import * as webpack from 'webpack'
|
|
||||||
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
|
|
||||||
|
|
||||||
export default (
|
|
||||||
config: webpack.Configuration,
|
|
||||||
options: CustomWebpackBrowserSchema,
|
|
||||||
targetOptions: TargetOptions
|
|
||||||
) => {
|
|
||||||
if (config.plugins) {
|
|
||||||
config.plugins.push(
|
|
||||||
codecovWebpackPlugin({
|
|
||||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
|
||||||
bundleName: 'paperless-ngx',
|
|
||||||
uploadToken: process.env.CODECOV_TOKEN,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
+49
-49
@@ -1277,7 +1277,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1791</context>
|
<context context-type="linenumber">1787</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1577733187050997705" datatype="html">
|
<trans-unit id="1577733187050997705" datatype="html">
|
||||||
@@ -2184,7 +2184,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">657</context>
|
<context context-type="linenumber">653</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1409</context>
|
<context context-type="linenumber">1405</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1792</context>
|
<context context-type="linenumber">1788</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -3688,7 +3688,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1362</context>
|
<context context-type="linenumber">1358</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -3800,7 +3800,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1845</context>
|
<context context-type="linenumber">1841</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6661109599266152398" datatype="html">
|
<trans-unit id="6661109599266152398" datatype="html">
|
||||||
@@ -3811,7 +3811,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1846</context>
|
<context context-type="linenumber">1842</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5162686434580248853" datatype="html">
|
<trans-unit id="5162686434580248853" datatype="html">
|
||||||
@@ -3822,7 +3822,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1847</context>
|
<context context-type="linenumber">1843</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8157388568390631653" datatype="html">
|
<trans-unit id="8157388568390631653" datatype="html">
|
||||||
@@ -5724,7 +5724,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1366</context>
|
<context context-type="linenumber">1362</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||||
@@ -7966,88 +7966,88 @@
|
|||||||
<source>Enter Password</source>
|
<source>Enter Password</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
<context context-type="linenumber">513</context>
|
<context context-type="linenumber">512</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5758784066858623886" datatype="html">
|
<trans-unit id="5758784066858623886" datatype="html">
|
||||||
<source>Error retrieving metadata</source>
|
<source>Error retrieving metadata</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">424</context>
|
<context context-type="linenumber">423</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2218903673684131427" datatype="html">
|
<trans-unit id="2218903673684131427" datatype="html">
|
||||||
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">526,528</context>
|
<context context-type="linenumber">525,527</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">986,988</context>
|
<context context-type="linenumber">982,984</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6357361810318120957" datatype="html">
|
<trans-unit id="6357361810318120957" datatype="html">
|
||||||
<source>Document was updated</source>
|
<source>Document was updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">652</context>
|
<context context-type="linenumber">648</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5154064822428631306" datatype="html">
|
<trans-unit id="5154064822428631306" datatype="html">
|
||||||
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
|
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">653</context>
|
<context context-type="linenumber">649</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8462497568316256794" datatype="html">
|
<trans-unit id="8462497568316256794" datatype="html">
|
||||||
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
|
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">654</context>
|
<context context-type="linenumber">650</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7967484035994732534" datatype="html">
|
<trans-unit id="7967484035994732534" datatype="html">
|
||||||
<source>Reload</source>
|
<source>Reload</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">656</context>
|
<context context-type="linenumber">652</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2907037627372942104" datatype="html">
|
<trans-unit id="2907037627372942104" datatype="html">
|
||||||
<source>Document reloaded with latest changes.</source>
|
<source>Document reloaded with latest changes.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">712</context>
|
<context context-type="linenumber">708</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6435639868943916539" datatype="html">
|
<trans-unit id="6435639868943916539" datatype="html">
|
||||||
<source>Document reloaded.</source>
|
<source>Document reloaded.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">723</context>
|
<context context-type="linenumber">719</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6142395741265832184" datatype="html">
|
<trans-unit id="6142395741265832184" datatype="html">
|
||||||
<source>Next document</source>
|
<source>Next document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">825</context>
|
<context context-type="linenumber">821</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="651985345816518480" datatype="html">
|
<trans-unit id="651985345816518480" datatype="html">
|
||||||
<source>Previous document</source>
|
<source>Previous document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">835</context>
|
<context context-type="linenumber">831</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2885986061416655600" datatype="html">
|
<trans-unit id="2885986061416655600" datatype="html">
|
||||||
<source>Close document</source>
|
<source>Close document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">843</context>
|
<context context-type="linenumber">839</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
|
||||||
@@ -8058,67 +8058,67 @@
|
|||||||
<source>Save document</source>
|
<source>Save document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">850</context>
|
<context context-type="linenumber">846</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1784543155727940353" datatype="html">
|
<trans-unit id="1784543155727940353" datatype="html">
|
||||||
<source>Save and close / next</source>
|
<source>Save and close / next</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">859</context>
|
<context context-type="linenumber">855</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7427704425579737895" datatype="html">
|
<trans-unit id="7427704425579737895" datatype="html">
|
||||||
<source>Error retrieving version content</source>
|
<source>Error retrieving version content</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">969</context>
|
<context context-type="linenumber">965</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3456881259945295697" datatype="html">
|
<trans-unit id="3456881259945295697" datatype="html">
|
||||||
<source>Error retrieving suggestions.</source>
|
<source>Error retrieving suggestions.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1030</context>
|
<context context-type="linenumber">1026</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2194092841814123758" datatype="html">
|
<trans-unit id="2194092841814123758" datatype="html">
|
||||||
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
<source>Document "<x id="PH" equiv-text="newValues.title"/>" saved successfully.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1242</context>
|
<context context-type="linenumber">1238</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1269</context>
|
<context context-type="linenumber">1265</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6626387786259219838" datatype="html">
|
<trans-unit id="6626387786259219838" datatype="html">
|
||||||
<source>Error saving document "<x id="PH" equiv-text="this.document().title"/>"</source>
|
<source>Error saving document "<x id="PH" equiv-text="this.document().title"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1275</context>
|
<context context-type="linenumber">1271</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="448882439049417053" datatype="html">
|
<trans-unit id="448882439049417053" datatype="html">
|
||||||
<source>Error saving document</source>
|
<source>Error saving document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1330</context>
|
<context context-type="linenumber">1326</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8410796510716511826" datatype="html">
|
<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>
|
<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-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1363</context>
|
<context context-type="linenumber">1359</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="282586936710748252" datatype="html">
|
<trans-unit id="282586936710748252" datatype="html">
|
||||||
<source>Documents can be restored prior to permanent deletion.</source>
|
<source>Documents can be restored prior to permanent deletion.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1364</context>
|
<context context-type="linenumber">1360</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<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>
|
<source>Error deleting document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1385</context>
|
<context context-type="linenumber">1381</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="619486176823357521" datatype="html">
|
<trans-unit id="619486176823357521" datatype="html">
|
||||||
<source>Reprocess confirm</source>
|
<source>Reprocess confirm</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1405</context>
|
<context context-type="linenumber">1401</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
<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>
|
<source>This operation will permanently recreate the archive file for this document.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1406</context>
|
<context context-type="linenumber">1402</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="302054111564709516" datatype="html">
|
<trans-unit id="302054111564709516" datatype="html">
|
||||||
<source>The archive file will be re-generated with the current settings.</source>
|
<source>The archive file will be re-generated with the current settings.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1407</context>
|
<context context-type="linenumber">1403</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4700389117298802932" datatype="html">
|
<trans-unit id="4700389117298802932" datatype="html">
|
||||||
<source>Reprocess operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
<source>Reprocess operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1417</context>
|
<context context-type="linenumber">1413</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4409560272830824468" datatype="html">
|
<trans-unit id="4409560272830824468" datatype="html">
|
||||||
<source>Error executing operation</source>
|
<source>Error executing operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1428</context>
|
<context context-type="linenumber">1424</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6030453331794586802" datatype="html">
|
<trans-unit id="6030453331794586802" datatype="html">
|
||||||
<source>Error downloading document</source>
|
<source>Error downloading document</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1491</context>
|
<context context-type="linenumber">1487</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4458954481601077369" datatype="html">
|
<trans-unit id="4458954481601077369" datatype="html">
|
||||||
<source>Page Fit</source>
|
<source>Page Fit</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1569</context>
|
<context context-type="linenumber">1565</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4663705961777238777" datatype="html">
|
<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>
|
<source>PDF edit operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1812</context>
|
<context context-type="linenumber">1808</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9043972994040261999" datatype="html">
|
<trans-unit id="9043972994040261999" datatype="html">
|
||||||
<source>Error executing PDF edit operation</source>
|
<source>Error executing PDF edit operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1824</context>
|
<context context-type="linenumber">1820</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6172690334763056188" datatype="html">
|
<trans-unit id="6172690334763056188" datatype="html">
|
||||||
<source>Please enter the current password before attempting to remove it.</source>
|
<source>Please enter the current password before attempting to remove it.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1835</context>
|
<context context-type="linenumber">1831</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="968660764814228922" datatype="html">
|
<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>
|
<source>Password removal operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1869</context>
|
<context context-type="linenumber">1865</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2282118435712883014" datatype="html">
|
<trans-unit id="2282118435712883014" datatype="html">
|
||||||
<source>Error executing password removal operation</source>
|
<source>Error executing password removal operation</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1883</context>
|
<context context-type="linenumber">1879</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3740891324955700797" datatype="html">
|
<trans-unit id="3740891324955700797" datatype="html">
|
||||||
<source>Print failed.</source>
|
<source>Print failed.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1929</context>
|
<context context-type="linenumber">1925</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6457245677384603573" datatype="html">
|
<trans-unit id="6457245677384603573" datatype="html">
|
||||||
<source>Error loading document for printing.</source>
|
<source>Error loading document for printing.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">1942</context>
|
<context context-type="linenumber">1938</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6085793215710522488" datatype="html">
|
<trans-unit id="6085793215710522488" datatype="html">
|
||||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">2012</context>
|
<context context-type="linenumber">2008</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||||
<context context-type="linenumber">2018</context>
|
<context context-type="linenumber">2014</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4958946940233632319" datatype="html">
|
<trans-unit id="4958946940233632319" datatype="html">
|
||||||
|
|||||||
+3
-6
@@ -39,7 +39,6 @@
|
|||||||
"uuid": "^14.0.1"
|
"uuid": "^14.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^22.0.1",
|
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.0.5",
|
"@angular-devkit/core": "^22.0.5",
|
||||||
"@angular-devkit/schematics": "^22.0.5",
|
"@angular-devkit/schematics": "^22.0.5",
|
||||||
@@ -48,10 +47,9 @@
|
|||||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
||||||
"@angular-eslint/schematics": "22.0.0",
|
"@angular-eslint/schematics": "22.0.0",
|
||||||
"@angular-eslint/template-parser": "22.0.0",
|
"@angular-eslint/template-parser": "22.0.0",
|
||||||
"@angular/build": "^22.0.5",
|
"@angular/build": "22.1.0-rc.0",
|
||||||
"@angular/cli": "~22.0.5",
|
"@angular/cli": "22.1.0-rc.0",
|
||||||
"@angular/compiler-cli": "~22.0.5",
|
"@angular/compiler-cli": "~22.0.5",
|
||||||
"@codecov/webpack-plugin": "^2.0.1",
|
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.0.0",
|
||||||
@@ -66,8 +64,7 @@
|
|||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3"
|
||||||
"webpack": "^5.107.2"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.26.0"
|
"packageManager": "pnpm@10.26.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1563
-1365
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,6 @@ packages:
|
|||||||
- "."
|
- "."
|
||||||
minimumReleaseAge: 10080
|
minimumReleaseAge: 10080
|
||||||
trustPolicy: no-downgrade
|
trustPolicy: no-downgrade
|
||||||
trustPolicyExclude:
|
|
||||||
- "chokidar@4.0.3"
|
|
||||||
- "semver@6.3.1 || 5.7.2"
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
canvas: true
|
canvas: true
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ from drf_spectacular.utils import extend_schema_field
|
|||||||
from guardian.utils import get_group_obj_perms_model
|
from guardian.utils import get_group_obj_perms_model
|
||||||
from guardian.utils import get_user_obj_perms_model
|
from guardian.utils import get_user_obj_perms_model
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.filters import BaseFilterBackend
|
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||||
|
|
||||||
@@ -51,7 +50,6 @@ from documents.models import ShareLink
|
|||||||
from documents.models import ShareLinkBundle
|
from documents.models import ShareLinkBundle
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import permitted_document_ids
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -1040,31 +1038,6 @@ class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
|
|||||||
return objects_with_perms | objects_owned | objects_unowned
|
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):
|
class ObjectOwnedPermissionsFilter(ObjectPermissionsFilter):
|
||||||
"""
|
"""
|
||||||
A filter backend that limits results to those where the requesting user
|
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
|
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
|
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
|
# Superuser: no permission filtering needed
|
||||||
return Q(**{f"{related_name}__deleted_at__isnull": True})
|
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})
|
return Q(**{f"{related_name}__id__in": permitted_ids})
|
||||||
|
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ def annotate_document_count_for_related_queryset(
|
|||||||
queryset,
|
queryset,
|
||||||
through_model=through_model,
|
through_model=through_model,
|
||||||
related_object_field=related_object_field,
|
related_object_field=related_object_field,
|
||||||
document_ids=permitted_document_ids(user),
|
document_ids=_permitted_document_ids(user),
|
||||||
target_field=target_field,
|
target_field=target_field,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -311,7 +311,6 @@ def sanity_check(*, raise_on_error: bool = True) -> str:
|
|||||||
def bulk_update_documents(document_ids) -> None:
|
def bulk_update_documents(document_ids) -> None:
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
document_ids = list(document_ids)
|
|
||||||
documents = Document.objects.filter(id__in=document_ids)
|
documents = Document.objects.filter(id__in=document_ids)
|
||||||
|
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
@@ -332,7 +331,6 @@ def bulk_update_documents(document_ids) -> None:
|
|||||||
if ai_config.llm_index_enabled:
|
if ai_config.llm_index_enabled:
|
||||||
update_llm_index(
|
update_llm_index(
|
||||||
rebuild=False,
|
rebuild=False,
|
||||||
document_ids=document_ids,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from unittest import mock
|
|||||||
import celery
|
import celery
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
from django.conf import settings
|
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 Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
@@ -48,8 +47,6 @@ from documents.models import Workflow
|
|||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.signals.handlers import run_workflows
|
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 ConsumeTaskMixin
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from documents.tests.utils import read_streaming_response
|
||||||
@@ -1215,91 +1212,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
[u1_doc1.id],
|
[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:
|
def test_pagination_results(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -401,10 +401,6 @@ class TestAIIndex(DirectoriesMixin, TestCase):
|
|||||||
"documents.tasks.update_llm_index",
|
"documents.tasks.update_llm_index",
|
||||||
) as update_llm_index,
|
) as update_llm_index,
|
||||||
):
|
):
|
||||||
doc_ids = [doc.pk for doc in docs]
|
tasks.bulk_update_documents([doc.pk for doc in docs])
|
||||||
tasks.bulk_update_documents(doc_ids)
|
|
||||||
self.assertEqual(update_document_in_llm_index.apply_async.call_count, 0)
|
self.assertEqual(update_document_in_llm_index.apply_async.call_count, 0)
|
||||||
update_llm_index.assert_called_once_with(
|
update_llm_index.assert_called_once()
|
||||||
rebuild=False,
|
|
||||||
document_ids=doc_ids,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -625,37 +625,6 @@ class TestAIChatStreamingView(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response["Content-Type"], "text/event-stream")
|
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")
|
@patch("documents.views.stream_chat_with_documents")
|
||||||
@override_settings(AI_ENABLED=True)
|
@override_settings(AI_ENABLED=True)
|
||||||
|
|||||||
+12
-24
@@ -133,7 +133,6 @@ from documents.file_handling import format_filename
|
|||||||
from documents.filters import CorrespondentFilterSet
|
from documents.filters import CorrespondentFilterSet
|
||||||
from documents.filters import CustomFieldFilterSet
|
from documents.filters import CustomFieldFilterSet
|
||||||
from documents.filters import DocumentFilterSet
|
from documents.filters import DocumentFilterSet
|
||||||
from documents.filters import DocumentPermissionsFilter
|
|
||||||
from documents.filters import DocumentsOrderingFilter
|
from documents.filters import DocumentsOrderingFilter
|
||||||
from documents.filters import DocumentTypeFilterSet
|
from documents.filters import DocumentTypeFilterSet
|
||||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
||||||
@@ -654,20 +653,6 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
|
|||||||
update_document_parent_tags(tag, new_parent)
|
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))
|
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
||||||
class DocumentTypeViewSet(
|
class DocumentTypeViewSet(
|
||||||
PermissionsAwareDocumentCountMixin,
|
PermissionsAwareDocumentCountMixin,
|
||||||
@@ -987,7 +972,7 @@ class DocumentViewSet(
|
|||||||
DjangoFilterBackend,
|
DjangoFilterBackend,
|
||||||
SearchFilter,
|
SearchFilter,
|
||||||
DocumentsOrderingFilter,
|
DocumentsOrderingFilter,
|
||||||
DocumentPermissionsFilter,
|
ObjectOwnedOrGrantedPermissionsFilter,
|
||||||
)
|
)
|
||||||
filterset_class = DocumentFilterSet
|
filterset_class = DocumentFilterSet
|
||||||
search_fields = ("title", "correspondent__name", "effective_content")
|
search_fields = ("title", "correspondent__name", "effective_content")
|
||||||
@@ -1529,7 +1514,16 @@ class DocumentViewSet(
|
|||||||
if not ai_config.ai_enabled:
|
if not ai_config.ai_enabled:
|
||||||
return HttpResponseBadRequest("AI is required for this feature")
|
return HttpResponseBadRequest("AI is required for this feature")
|
||||||
|
|
||||||
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
|
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
|
||||||
llm_cache_backend = (
|
llm_cache_backend = (
|
||||||
f"{ai_config.llm_backend}:{output_language}"
|
f"{ai_config.llm_backend}:{output_language}"
|
||||||
if output_language
|
if output_language
|
||||||
@@ -2271,14 +2265,8 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
Document,
|
Document,
|
||||||
)
|
)
|
||||||
|
|
||||||
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
|
|
||||||
|
|
||||||
response = StreamingHttpResponse(
|
response = StreamingHttpResponse(
|
||||||
stream_chat_with_documents(
|
stream_chat_with_documents(query_str=question, documents=documents),
|
||||||
query_str=question,
|
|
||||||
documents=documents,
|
|
||||||
output_language=output_language,
|
|
||||||
),
|
|
||||||
content_type="text/event-stream",
|
content_type="text/event-stream",
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-07-27 19:34+0000\n"
|
"POT-Creation-Date: 2026-07-25 07:12+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -21,39 +21,39 @@ msgstr ""
|
|||||||
msgid "Documents"
|
msgid "Documents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:472
|
#: documents/filters.py:470
|
||||||
msgid "Value must be valid JSON."
|
msgid "Value must be valid JSON."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:491
|
#: documents/filters.py:489
|
||||||
msgid "Invalid custom field query expression"
|
msgid "Invalid custom field query expression"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:501
|
#: documents/filters.py:499
|
||||||
msgid "Invalid expression list. Must be nonempty."
|
msgid "Invalid expression list. Must be nonempty."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:522
|
#: documents/filters.py:520
|
||||||
msgid "Invalid logical operator {op!r}"
|
msgid "Invalid logical operator {op!r}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:536
|
#: documents/filters.py:534
|
||||||
msgid "Maximum number of query conditions exceeded."
|
msgid "Maximum number of query conditions exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:600
|
#: documents/filters.py:598
|
||||||
msgid "{name!r} is not a valid custom field."
|
msgid "{name!r} is not a valid custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:637
|
#: documents/filters.py:635
|
||||||
msgid "{data_type} does not support query expr {expr!r}."
|
msgid "{data_type} does not support query expr {expr!r}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:752 documents/models.py:136
|
#: documents/filters.py:750 documents/models.py:136
|
||||||
msgid "Maximum nesting depth exceeded."
|
msgid "Maximum nesting depth exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:1094
|
#: documents/filters.py:1067
|
||||||
msgid "Custom field not found"
|
msgid "Custom field not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1352,7 +1352,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:522 documents/serialisers.py:874
|
#: documents/serialisers.py:522 documents/serialisers.py:874
|
||||||
#: documents/serialisers.py:2763 documents/views.py:299 documents/views.py:2553
|
#: documents/serialisers.py:2763 documents/views.py:298 documents/views.py:2541
|
||||||
#: paperless_mail/serialisers.py:155
|
#: paperless_mail/serialisers.py:155
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1393,7 +1393,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2849 documents/views.py:4512
|
#: documents/serialisers.py:2849 documents/views.py:4500
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1661,36 +1661,36 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:292 documents/views.py:2550
|
#: documents/views.py:291 documents/views.py:2538
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1562
|
#: documents/views.py:1556
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1571
|
#: documents/views.py:1565
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2375 documents/views.py:2696
|
#: documents/views.py:2363 documents/views.py:2684
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4524
|
#: documents/views.py:4512
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4570
|
#: documents/views.py:4558
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4631
|
#: documents/views.py:4619
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4641
|
#: documents/views.py:4629
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -28,22 +28,11 @@ CHAT_PROMPT_TMPL = (
|
|||||||
"---------------------\n"
|
"---------------------\n"
|
||||||
"Using only the context above, answer the query. "
|
"Using only the context above, answer the query. "
|
||||||
"Do not use prior knowledge.\n"
|
"Do not use prior knowledge.\n"
|
||||||
"{output_language_line}"
|
|
||||||
"Query: {query_str}\n"
|
"Query: {query_str}\n"
|
||||||
"Answer:"
|
"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(
|
def _build_document_reference(
|
||||||
document: Document,
|
document: Document,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
@@ -90,27 +79,15 @@ def _format_chat_metadata_trailer(references: list[dict[str, int | str]]) -> str
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def stream_chat_with_documents(
|
def stream_chat_with_documents(query_str: str, documents: list[Document]):
|
||||||
query_str: str,
|
|
||||||
documents: list[Document],
|
|
||||||
output_language: str | None = None,
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
yield from _stream_chat_with_documents(
|
yield from _stream_chat_with_documents(query_str, documents)
|
||||||
query_str,
|
|
||||||
documents,
|
|
||||||
output_language=output_language,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to stream document chat response: %s", e)
|
logger.exception("Failed to stream document chat response: %s", e)
|
||||||
yield CHAT_ERROR_MESSAGE
|
yield CHAT_ERROR_MESSAGE
|
||||||
|
|
||||||
|
|
||||||
def _stream_chat_with_documents(
|
def _stream_chat_with_documents(query_str: str, documents: list[Document]):
|
||||||
query_str: str,
|
|
||||||
documents: list[Document],
|
|
||||||
output_language: str | None = None,
|
|
||||||
):
|
|
||||||
if not documents:
|
if not documents:
|
||||||
yield CHAT_NO_CONTENT_MESSAGE
|
yield CHAT_NO_CONTENT_MESSAGE
|
||||||
return
|
return
|
||||||
@@ -148,7 +125,7 @@ def _stream_chat_with_documents(
|
|||||||
|
|
||||||
references = _get_document_references(documents, top_nodes)
|
references = _get_document_references(documents, top_nodes)
|
||||||
|
|
||||||
prompt_template = PromptTemplate(template=_build_chat_prompt(output_language))
|
prompt_template = PromptTemplate(template=CHAT_PROMPT_TMPL)
|
||||||
response_synthesizer = get_response_synthesizer(
|
response_synthesizer = get_response_synthesizer(
|
||||||
llm=client.llm,
|
llm=client.llm,
|
||||||
prompt_helper=get_rag_prompt_helper(
|
prompt_helper=get_rag_prompt_helper(
|
||||||
|
|||||||
@@ -328,16 +328,8 @@ def update_llm_index(
|
|||||||
*,
|
*,
|
||||||
iter_wrapper: IterWrapper[Document] = identity,
|
iter_wrapper: IterWrapper[Document] = identity,
|
||||||
rebuild=False,
|
rebuild=False,
|
||||||
document_ids: Iterable[int] | None = None,
|
|
||||||
) -> str:
|
) -> 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:
|
with write_store() as store:
|
||||||
try:
|
try:
|
||||||
with _exclude_readers():
|
with _exclude_readers():
|
||||||
@@ -353,11 +345,7 @@ def update_llm_index(
|
|||||||
"LLM index migration requires re-embedding; forcing rebuild.",
|
"LLM index migration requires re-embedding; forcing rebuild.",
|
||||||
)
|
)
|
||||||
rebuild = True
|
rebuild = True
|
||||||
documents = Document.objects.select_related(
|
documents = Document.objects.all()
|
||||||
"correspondent",
|
|
||||||
"document_type",
|
|
||||||
"storage_path",
|
|
||||||
).prefetch_related("tags")
|
|
||||||
no_documents = not documents.exists()
|
no_documents = not documents.exists()
|
||||||
|
|
||||||
# Fast exit before touching config: nothing to index and no existing index.
|
# Fast exit before touching config: nothing to index and no existing index.
|
||||||
@@ -391,14 +379,9 @@ def update_llm_index(
|
|||||||
store.add(nodes)
|
store.add(nodes)
|
||||||
msg = "LLM index rebuilt successfully."
|
msg = "LLM index rebuilt successfully."
|
||||||
else:
|
else:
|
||||||
scoped_documents = (
|
|
||||||
documents.filter(id__in=document_ids)
|
|
||||||
if document_ids is not None
|
|
||||||
else documents
|
|
||||||
)
|
|
||||||
existing = store.get_modified_times()
|
existing = store.get_modified_times()
|
||||||
changed = 0
|
changed = 0
|
||||||
for document in iter_wrapper(scoped_documents):
|
for document in iter_wrapper(documents):
|
||||||
doc_id = str(document.id)
|
doc_id = str(document.id)
|
||||||
if existing.get(doc_id) == document.modified.isoformat():
|
if existing.get(doc_id) == document.modified.isoformat():
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -197,8 +197,6 @@ def test_update_llm_index(
|
|||||||
mock_queryset = MagicMock()
|
mock_queryset = MagicMock()
|
||||||
mock_queryset.exists.return_value = True
|
mock_queryset.exists.return_value = True
|
||||||
mock_queryset.__iter__.return_value = iter([real_document])
|
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
|
mock_all.return_value = mock_queryset
|
||||||
build_document_node.return_value = []
|
build_document_node.return_value = []
|
||||||
indexing.update_llm_index(rebuild=True)
|
indexing.update_llm_index(rebuild=True)
|
||||||
@@ -218,8 +216,6 @@ def test_update_llm_index_rebuilds_on_model_name_change(
|
|||||||
mock_queryset = MagicMock()
|
mock_queryset = MagicMock()
|
||||||
mock_queryset.exists.return_value = True
|
mock_queryset.exists.return_value = True
|
||||||
mock_queryset.__iter__.return_value = iter([real_document])
|
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
|
mock_all.return_value = mock_queryset
|
||||||
with patch(
|
with patch(
|
||||||
"paperless_ai.indexing.get_configured_model_name",
|
"paperless_ai.indexing.get_configured_model_name",
|
||||||
@@ -232,8 +228,6 @@ def test_update_llm_index_rebuilds_on_model_name_change(
|
|||||||
mock_queryset = MagicMock()
|
mock_queryset = MagicMock()
|
||||||
mock_queryset.exists.return_value = True
|
mock_queryset.exists.return_value = True
|
||||||
mock_queryset.__iter__.return_value = iter([real_document])
|
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
|
mock_all.return_value = mock_queryset
|
||||||
with patch(
|
with patch(
|
||||||
"paperless_ai.indexing.get_configured_model_name",
|
"paperless_ai.indexing.get_configured_model_name",
|
||||||
@@ -264,8 +258,6 @@ def test_update_llm_index_partial_update(
|
|||||||
mock_queryset = MagicMock()
|
mock_queryset = MagicMock()
|
||||||
mock_queryset.exists.return_value = True
|
mock_queryset.exists.return_value = True
|
||||||
mock_queryset.__iter__.return_value = iter([real_document, doc2])
|
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
|
mock_all.return_value = mock_queryset
|
||||||
|
|
||||||
indexing.update_llm_index(rebuild=True)
|
indexing.update_llm_index(rebuild=True)
|
||||||
@@ -294,22 +286,6 @@ def test_update_llm_index_partial_update(
|
|||||||
assert store.table_exists(), (
|
assert store.table_exists(), (
|
||||||
"Expected the vector store table to exist after incremental update"
|
"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
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from paperless_ai import chat
|
|||||||
from paperless_ai import indexing
|
from paperless_ai import indexing
|
||||||
from paperless_ai.chat import CHAT_ERROR_MESSAGE
|
from paperless_ai.chat import CHAT_ERROR_MESSAGE
|
||||||
from paperless_ai.chat import CHAT_METADATA_DELIMITER
|
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
|
from paperless_ai.chat import stream_chat_with_documents
|
||||||
|
|
||||||
|
|
||||||
@@ -60,26 +59,6 @@ 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
|
@pytest.mark.django_db
|
||||||
def test_stream_chat_with_one_document_retrieval(
|
def test_stream_chat_with_one_document_retrieval(
|
||||||
mock_document,
|
mock_document,
|
||||||
|
|||||||
Reference in New Issue
Block a user