mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-10 12:53:20 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f37f49dd0 | ||
|
|
d27be0839c | ||
|
|
fc242bb570 | ||
|
|
b192a419fd | ||
|
|
3986150f95 | ||
|
|
ee5588ade3 | ||
|
|
2635a12281 | ||
|
|
1f396e51f3 |
@@ -227,7 +227,6 @@ Version-aware endpoints:
|
||||
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
||||
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
||||
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
||||
- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided.
|
||||
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
|
||||
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
# Chat Unbounded Document Scan Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stop `ChatStreamingView`'s "chat with my whole archive" path from materializing every accessible `Document` into Python memory on every chat message; bound the cost to the vector-store `IN`-filter id list plus at most `CHAT_RETRIEVER_TOP_K` (5) documents for the reference/permission lookup.
|
||||
|
||||
**Architecture:** Change `documents` from a materialized `list[Document]` to a lazy `QuerySet[Document]` threaded through `ChatStreamingView.post` -> `stream_chat_with_documents` -> `_stream_chat_with_documents` -> `_get_document_references`. Build the vector-store `IN` filter from `documents.values_list("pk", flat=True)` (ids only, no row hydration) instead of iterating full `Document` instances. Reorder `_get_document_references` to run `retriever.retrieve()` first, then permission-check/hydrate only the (≤5) documents that `top_nodes` actually reference via `documents.filter(pk__in=candidate_ids)`, instead of hydrating every accessible document up front.
|
||||
|
||||
**Tech Stack:** Django ORM (QuerySet), llama-index (`MetadataFilters`, `VectorIndexRetriever`), pytest + pytest-django.
|
||||
|
||||
## Background
|
||||
|
||||
`ChatStreamingView.post` (`src/documents/views.py`), when the request has no `document_id`
|
||||
(i.e. "chat with my whole archive" rather than "chat with this one document"), builds a
|
||||
`QuerySet` of every `Document` the requesting user is permitted to view and passes it straight
|
||||
into `stream_chat_with_documents(query_str, documents)`
|
||||
(`src/paperless_ai/chat.py`), which calls into `_stream_chat_with_documents`. Two places there
|
||||
force-materialize the entire queryset into Python objects, on **every single chat message**:
|
||||
|
||||
1. `_document_id_filters(str(doc.pk) for doc in documents)` -- iterates every accessible
|
||||
document just to build a `MetadataFilter(key="document_id", operator=IN,
|
||||
value=sorted(doc_ids))` for the vector-store query.
|
||||
2. `_get_document_references`'s `allowed_documents = {doc.pk: doc for doc in documents}` --
|
||||
hydrates every accessible `Document` row into a dict, just to look up at most
|
||||
`MAX_CHAT_REFERENCES = 3` of them later.
|
||||
|
||||
Meanwhile the actual retrieval only ever wants `CHAT_RETRIEVER_TOP_K = 5` nodes, and shows at
|
||||
most 3 references. So the cost of _every_ chat message -- not a background job, an interactive
|
||||
request a user is staring at a spinner for -- scales with total accessible-document count, not
|
||||
with the ~5 documents that actually matter to the answer. This is worse than an equivalent
|
||||
scan in a background Celery task: a user is waiting on it in real time, on every message, and
|
||||
the cost grows as the library grows regardless of how good or bad the actual answer needs to
|
||||
be.
|
||||
|
||||
**What this plan fixes (and what it deliberately doesn't):**
|
||||
|
||||
1. Stop materializing full `Document` rows for the filter step -- `_document_id_filters` only
|
||||
needs a list of ids, not hydrated rows (Task 2, Step 3).
|
||||
2. Stop permission-checking/hydrating the whole accessible set before knowing which documents
|
||||
were even retrieved -- flip the order so retrieval happens first (bounded by
|
||||
`CHAT_RETRIEVER_TOP_K = 5`), then permission-check only those results (Task 2, Step 4). The
|
||||
permission check itself is unchanged in substance -- a document is only surfaced if it's in
|
||||
the caller's permission-scoped queryset -- only its timing and the amount of data it touches
|
||||
change.
|
||||
3. **Out of scope:** the vector-store-side `IN (...)` filter still needs the full list of
|
||||
accessible document ids to constrain the KNN search to permitted documents -- that's
|
||||
inherent to "chat with my whole (permitted) archive" and can't be avoided by filtering after
|
||||
the fact (doing so would leak un-permitted document content into the LLM context). Whether
|
||||
that `IN`-list itself is a performance problem for the vector store at very large scale is a
|
||||
separate, unimplemented investigation and is explicitly not addressed by this plan.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend lint/format: ruff, line length 88, double quotes, single-line isort imports (from `CLAUDE.md`).
|
||||
- Type checking: mypy + pyrefly; do not introduce new violations beyond the frozen baseline (`.mypy-baseline.txt`, `.pyrefly-baseline.json`).
|
||||
- Tests: pytest/pytest-django; match the style of the file being edited (`src/paperless_ai/tests/test_chat.py` is already idiomatic pytest with fixtures).
|
||||
- The existing permission check semantics MUST be preserved exactly: a document referenced by a retrieved node is only surfaced/cited if it is in the caller's permission-scoped `documents` queryset. No behavior change to what a user is allowed to see, only to when/how much is loaded to check it.
|
||||
- Preserve `output_language` threading through `stream_chat_with_documents` / `_stream_chat_with_documents` unchanged -- it is unrelated to this fix but must not be dropped by a careless signature rewrite.
|
||||
- Do not touch the vector-store-side `IN (...)` filter question (see Background, point 3) -- out of scope for this plan.
|
||||
|
||||
**Suggested delegation (Claude Code `Agent` tool `subagent_type` + model tier):**
|
||||
|
||||
- Task 0 (benchmark baseline -- open-ended: choosing a harness, interpreting numbers, deciding what "proves the bug" means): `python-pro` or `django-developer` at **Sonnet** tier. Not mechanical enough for Haiku -- it requires judgment about what to measure and whether the resulting numbers actually support the claimed scaling behavior, and it's the evidence the rest of the plan's justification rests on.
|
||||
- Task 1 (test rewrite -- mechanical: swap list literals for querysets/MagicMocks per the exact snippets already written out in this plan): `django-developer` at **Haiku** tier. The transformations are fully specified here (copy-paste-adjacent), so a fast/cheap model is sufficient; escalate to Sonnet only if the agent reports the current file has drifted from what this plan quotes.
|
||||
- Task 2 (`chat.py` rework -- the actual bug fix, changes runtime permission-check ordering): `django-developer` at **Sonnet** tier (or whatever the session's default is). This is the correctness-sensitive core of the change -- worth the stronger model even though the code is also fully specified, because a subtle mistake here (e.g. querying `documents` before `.filter(pk__in=...)` narrows it) reintroduces the exact bug being fixed.
|
||||
- Task 3 (`views.py` one-line change + locating/running the right view tests): `django-developer` at **Haiku** tier for the one-line edit; if the test-discovery grep in Step 2 turns up ambiguity, let it escalate or hand off rather than guessing.
|
||||
- Task 4 (full verification, lint/type baselines, before/after benchmark comparison): a `code-reviewer` subagent (or the `code-review` skill) at **Sonnet** tier or above for the correctness/permission-scoping review, paired with whichever agent ran Task 0 (same one, if possible, so it can compare against numbers it already understands) for the benchmark re-run in Step 0. Not a good candidate for Haiku -- both the permission-scoping check and the benchmark interpretation require judgment.
|
||||
- Use `superpowers:subagent-driven-development` to run Tasks 0-3 as independent-but-ordered subagent dispatches with review checkpoints between them, per this plan's header.
|
||||
|
||||
---
|
||||
|
||||
## Current code (as of `dev` commit `fc242bb57`, for reference while implementing)
|
||||
|
||||
Re-verify these line numbers against the live files before editing -- they will drift as other
|
||||
work lands on `dev`.
|
||||
|
||||
`src/documents/views.py:2245-2286` (`ChatStreamingView.post`):
|
||||
|
||||
```python
|
||||
class ChatStreamingView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated, ViewDocumentsPermissions)
|
||||
serializer_class = ChatStreamingSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
request.compress_exempt = True
|
||||
ai_config = AIConfig()
|
||||
if not ai_config.ai_enabled:
|
||||
return HttpResponseBadRequest("AI is required for this feature")
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
question = serializer.validated_data["q"]
|
||||
|
||||
doc_id = serializer.validated_data.get("document_id")
|
||||
|
||||
if doc_id:
|
||||
try:
|
||||
document = Document.objects.get(id=doc_id)
|
||||
except Document.DoesNotExist:
|
||||
return HttpResponseBadRequest("Document not found")
|
||||
|
||||
if not has_perms_owner_aware(request.user, "view_document", document):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
documents = [document]
|
||||
else:
|
||||
documents = Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
)
|
||||
|
||||
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
stream_chat_with_documents(
|
||||
query_str=question,
|
||||
documents=documents,
|
||||
output_language=output_language,
|
||||
),
|
||||
content_type="text/event-stream",
|
||||
)
|
||||
return response
|
||||
```
|
||||
|
||||
Note: the whole-library `else` branch already returns a `QuerySet` (`permitted_document_ids`
|
||||
returns a lazy `QuerySet[int]`, see `src/documents/permissions.py`) -- the bug is entirely
|
||||
inside `chat.py`, which force-materializes it. Only the single-document `if` branch needs to
|
||||
change (`[document]` -> a one-row `QuerySet`), purely so both branches share the same type.
|
||||
|
||||
`src/paperless_ai/chat.py` (`_get_document_references`, `stream_chat_with_documents`,
|
||||
`_stream_chat_with_documents` -- abridged excerpt, elisions and inline comments below are
|
||||
annotations for this plan, not literal source; re-read the live file rather than treating this
|
||||
as a copy-paste-ready contiguous block):
|
||||
|
||||
```python
|
||||
def _get_document_references(
|
||||
documents: list[Document],
|
||||
top_nodes: list,
|
||||
) -> list[dict[str, int | str]]:
|
||||
allowed_documents = {doc.pk: doc for doc in documents} # <-- full materialization #1
|
||||
...
|
||||
|
||||
|
||||
def stream_chat_with_documents(
|
||||
query_str: str,
|
||||
documents: list[Document],
|
||||
output_language: str | None = None,
|
||||
):
|
||||
try:
|
||||
yield from _stream_chat_with_documents(
|
||||
query_str,
|
||||
documents,
|
||||
output_language=output_language,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to stream document chat response: %s", e)
|
||||
yield CHAT_ERROR_MESSAGE
|
||||
|
||||
|
||||
def _stream_chat_with_documents(
|
||||
query_str: str,
|
||||
documents: list[Document],
|
||||
output_language: str | None = None,
|
||||
):
|
||||
if not documents:
|
||||
yield CHAT_NO_CONTENT_MESSAGE
|
||||
return
|
||||
...
|
||||
filters = _document_id_filters(str(doc.pk) for doc in documents) # <-- full materialization #2
|
||||
...
|
||||
references = _get_document_references(documents, top_nodes)
|
||||
```
|
||||
|
||||
All three signatures need to carry `output_language: str | None = None` through unchanged --
|
||||
this parameter is unrelated to the fix but must not be dropped.
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `src/paperless_ai/chat.py` -- change `documents` parameter type from `list[Document]` to `QuerySet[Document]` across `stream_chat_with_documents`, `_stream_chat_with_documents`, `_get_document_references`; rework `_get_document_references` to defer hydration until after retrieval.
|
||||
- Modify: `src/documents/views.py` -- `ChatStreamingView.post` builds a `QuerySet[Document]` for the single-document branch (instead of `[document]`) so both branches share the same lazy type; the whole-library branch already returns a `QuerySet` via `permitted_document_ids` and needs no structural change (just stops being force-materialized downstream).
|
||||
- Modify: `src/paperless_ai/tests/test_chat.py` -- update existing tests to pass `QuerySet[Document]` (real, via `DocumentFactory` + `django_db`, or a `QuerySet`-shaped `MagicMock` where no DB is wanted) instead of plain lists; add a regression test proving the reference lookup only queries documents actually referenced by `top_nodes`, not the whole passed queryset.
|
||||
- No change expected to `src/documents/tests/test_views.py` (search for the chat streaming view test class with `rg -n "ChatStreamingView|class.*Chat" src/documents/tests/test_views.py` before starting -- confirm the exact class name, it may have moved since this plan was drafted) -- it patches `stream_chat_with_documents` entirely and never inspects the `documents` argument's type, but Task 4 runs it to confirm.
|
||||
- Add: a benchmark script or pytest-based benchmark test (exact location decided in Task 0 Step 1) that seeds a large document library and measures query count + wall time through `_stream_chat_with_documents`, to be run before (Task 0) and after (Task 4) the fix and compared.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Benchmark the current (unfixed) behavior -- prove the bug's cost shape before changing code
|
||||
|
||||
**Files:**
|
||||
|
||||
- Add: a benchmark script/test, e.g. `src/paperless_ai/tests/test_chat_benchmark.py` (pytest-based, easiest to re-run identically in Task 4) or a one-off management-command-style script using `src/profiling.py`'s existing `profile_block` context manager (already in this repo's root, wraps `tracemalloc` + Django query counting + wall time -- see its docstring). Prefer the pytest version so Task 4 can literally re-run the same file and diff the numbers; a throwaway script is fine too if you'd rather not commit a benchmark test permanently to the suite -- ask before committing one either way, since it's not core test coverage.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `stream_chat_with_documents`, `_get_document_references`, `_document_id_filters` as they currently exist (`list[Document]`-based, unfixed).
|
||||
- Produces: a recorded baseline (query count, wall time) at multiple library sizes, referenced again in Task 4's "after" run. This task makes no code changes to `chat.py`/`views.py` -- benchmark only.
|
||||
|
||||
- [ ] **Step 1: Decide and set up the benchmark harness**
|
||||
|
||||
Seed libraries at a few sizes (e.g. 10, 100, 1000 documents) via
|
||||
`DocumentFactory.create_batch(n)` (see `src/documents/tests/factories.py`), matching the
|
||||
pattern already used in this plan's own `test_get_document_references_only_queries_referenced_documents`
|
||||
test (Task 1, Step 3) which seeds 200. Wrap the call path in Django's
|
||||
`django.test.utils.CaptureQueriesContext` (or the `django_assert_num_queries` fixture for a
|
||||
fixed expected count, but here you want the _actual_ count at each size, not just an
|
||||
assertion) plus `time.perf_counter()` for wall time. `src/profiling.py`'s `profile_block`
|
||||
context manager already bundles both (query count/time + memory) if you'd rather reuse it
|
||||
than hand-roll `CaptureQueriesContext`.
|
||||
|
||||
- [ ] **Step 2: Run the benchmark against the two hot spots described in Background**
|
||||
|
||||
Specifically measure, at each library size:
|
||||
|
||||
1. `_document_id_filters(str(doc.pk) for doc in documents)` (`chat.py`) -- the filter-list
|
||||
build.
|
||||
2. `_get_document_references(documents, top_nodes)` (`chat.py`) -- the reference
|
||||
lookup, with `top_nodes` fixed at a small constant (e.g. 1-3 nodes) regardless of library
|
||||
size, to isolate the effect of accessible-library size on this specific function (this is
|
||||
the function the fix changes the most).
|
||||
|
||||
Record: query count and wall time for each, at each library size. Expect (unfixed) roughly
|
||||
linear-in-library-size query time/row-hydration cost for #2 in particular, since
|
||||
`{doc.pk: doc for doc in documents}` hydrates every row.
|
||||
|
||||
- [ ] **Step 3: Record the baseline numbers**
|
||||
|
||||
Write the baseline numbers into this plan file (append a small table under this task) or into
|
||||
a scratch note referenced from here -- whichever the implementer running this task prefers, as
|
||||
long as Task 4 can find and compare against it. Do not proceed to Task 1 until a baseline
|
||||
exists; the point of this task is to have something to compare the fix against, not to block
|
||||
indefinitely on a perfect benchmark harness.
|
||||
|
||||
- [ ] **Step 4: Commit (if the benchmark harness itself is a pytest file worth keeping)**
|
||||
|
||||
```bash
|
||||
git add src/paperless_ai/tests/test_chat_benchmark.py # or wherever Step 1 put it
|
||||
git commit -m "Bench: baseline query count/wall time for chat document reference lookup"
|
||||
```
|
||||
|
||||
If instead you used a throwaway script (not added to the pytest suite), skip this commit --
|
||||
just keep the recorded numbers from Step 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rewrite chat tests to use QuerySets and add the bounded-lookup regression test (RED)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/paperless_ai/tests/test_chat.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `stream_chat_with_documents(query_str: str, documents, output_language: str | None = None)` (current signature, still `list[Document]` at this point -- these tests will fail until Task 2 lands).
|
||||
- Produces: nothing new for later tasks to consume; this task only changes test fixtures/assertions.
|
||||
|
||||
- [ ] **Step 1: Replace list-based `documents` fixtures with `QuerySet`-shaped values**
|
||||
|
||||
In `src/paperless_ai/tests/test_chat.py`, the `mock_document` fixture (around line 39-46) is a
|
||||
`MagicMock`, not a real row, so it cannot be used with a real `QuerySet.filter(pk=...)`
|
||||
lookup. Replace its use in `test_stream_chat_with_one_document_retrieval` with a
|
||||
real `DocumentFactory.create()` instance and pass `Document.objects.filter(pk=document.pk)`:
|
||||
|
||||
```python
|
||||
from documents.models import Document
|
||||
from documents.tests.factories import DocumentFactory
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_stream_chat_with_one_document_retrieval(patch_embed_nodes) -> None:
|
||||
document = DocumentFactory.create(title="Test Document", content="ignored")
|
||||
documents = Document.objects.filter(pk=document.pk)
|
||||
with (
|
||||
patch("paperless_ai.chat.AIClient") as mock_client_cls,
|
||||
patch("paperless_ai.chat.load_or_build_index") as mock_load_index,
|
||||
patch(
|
||||
"llama_index.core.query_engine.RetrieverQueryEngine.from_args",
|
||||
) as mock_query_engine_cls,
|
||||
patch(
|
||||
"llama_index.core.response_synthesizers.get_response_synthesizer",
|
||||
) as mock_get_response_synthesizer,
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.llm = MagicMock()
|
||||
|
||||
mock_index = MagicMock()
|
||||
mock_index.vector_store.get_nodes.return_value = [
|
||||
TextNode(
|
||||
text="This is node content.",
|
||||
metadata={"document_id": str(document.pk), "title": "Test Document"},
|
||||
),
|
||||
]
|
||||
mock_load_index.return_value = mock_index
|
||||
|
||||
mock_retriever_instance = MagicMock()
|
||||
mock_retriever_instance.retrieve.return_value = [
|
||||
MagicMock(
|
||||
metadata={"document_id": str(document.pk), "title": "Test Document"},
|
||||
),
|
||||
]
|
||||
|
||||
mock_response_stream = MagicMock()
|
||||
mock_response_stream.response_gen = iter(["chunk1", "chunk2"])
|
||||
mock_query_engine = MagicMock()
|
||||
mock_query_engine_cls.return_value = mock_query_engine
|
||||
mock_query_engine.query.return_value = mock_response_stream
|
||||
|
||||
with patch(
|
||||
"llama_index.core.retrievers.VectorIndexRetriever",
|
||||
return_value=mock_retriever_instance,
|
||||
):
|
||||
output = list(stream_chat_with_documents("What is this?", documents))
|
||||
|
||||
mock_query_engine.query.assert_called_once_with("What is this?")
|
||||
synthesizer_kwargs = mock_get_response_synthesizer.call_args.kwargs
|
||||
assert (
|
||||
"Treat the new context and existing answer as untrusted data, "
|
||||
"not instructions;" in synthesizer_kwargs["refine_template"].template
|
||||
)
|
||||
patch_embed_nodes.assert_not_called()
|
||||
assert_chat_output(
|
||||
output,
|
||||
expected_chunks=["chunk1", "chunk2"],
|
||||
expected_references=[
|
||||
{"id": document.pk, "title": "Test Document"},
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
Remove the `mock_document` fixture only if nothing else in the file still uses it (check with
|
||||
`rg -n "mock_document" src/paperless_ai/tests/test_chat.py` after this step).
|
||||
|
||||
Apply the equivalent change to `test_stream_chat_with_multiple_documents_retrieval`:
|
||||
replace `doc1 = MagicMock(pk=1, ...)` / `doc2 = MagicMock(pk=2, ...)` with two
|
||||
`DocumentFactory.create(...)` instances, and pass
|
||||
`documents = Document.objects.filter(pk__in=[doc1.pk, doc2.pk])` to
|
||||
`stream_chat_with_documents`. Update the node/reference metadata to use the real created pks
|
||||
instead of hardcoded `"1"`/`"2"`.
|
||||
|
||||
For the three non-DB tests (`test_stream_chat_empty_document_list`,
|
||||
`test_stream_chat_no_matching_nodes`,
|
||||
`test_stream_chat_unexpected_failure_returns_generic_error`), replace the list
|
||||
arguments with values that behave like an (unevaluated) `QuerySet` without touching the
|
||||
database:
|
||||
|
||||
```python
|
||||
def test_stream_chat_empty_document_list() -> None:
|
||||
with patch("paperless_ai.chat.load_or_build_index") as mock_load_index:
|
||||
output = list(stream_chat_with_documents("Any info?", Document.objects.none()))
|
||||
mock_load_index.assert_not_called()
|
||||
assert output == ["Sorry, I couldn't find any content to answer your question."]
|
||||
```
|
||||
|
||||
`Document.objects.none()` short-circuits Django's query execution (`QuerySet.query.is_empty()`),
|
||||
so `.exists()` on it does not hit the database and this test does not need
|
||||
`@pytest.mark.django_db`.
|
||||
|
||||
For `test_stream_chat_no_matching_nodes` and
|
||||
`test_stream_chat_unexpected_failure_returns_generic_error`, which pass `[MagicMock(pk=1)]`
|
||||
today: these need a queryset-like object that reports non-empty and yields at least one pk,
|
||||
without a real DB row (they never reach `_get_document_references` -- one returns before
|
||||
retrieval finds nodes, the other raises during retrieval). Use a `MagicMock` configured to
|
||||
mimic the two methods actually called before that point:
|
||||
|
||||
```python
|
||||
def _fake_documents_queryset(pks: list[int]) -> MagicMock:
|
||||
qs = MagicMock()
|
||||
qs.exists.return_value = bool(pks)
|
||||
qs.values_list.return_value = pks
|
||||
return qs
|
||||
```
|
||||
|
||||
Add this helper near the top of the file (after `assert_chat_output`) and use
|
||||
`_fake_documents_queryset([1])` in place of `[MagicMock(pk=1)]` in both tests.
|
||||
|
||||
Add the necessary import: `from documents.models import Document` at the top of the file.
|
||||
|
||||
- [ ] **Step 2: Rewrite the two `TestStreamChatRetrieval` tests to pass a QuerySet**
|
||||
|
||||
Both `test_no_nodes_yields_no_content_message` and
|
||||
`test_chat_filter_contains_only_requested_document_ids` (in class `TestStreamChatRetrieval`)
|
||||
already use real `DocumentFactory` documents and `django_db`. Change the calls:
|
||||
|
||||
```python
|
||||
out = list(chat.stream_chat_with_documents("question?", Document.objects.filter(pk=doc.pk)))
|
||||
...
|
||||
list(chat.stream_chat_with_documents("question?", Document.objects.filter(pk=included.pk)))
|
||||
```
|
||||
|
||||
(`doc`/`included` stay single real documents; no other change needed in these tests.)
|
||||
|
||||
- [ ] **Step 3: Add the regression test for bounded reference lookup**
|
||||
|
||||
Add a new test proving `_get_document_references` only touches documents that `top_nodes`
|
||||
actually reference, not every document in the passed queryset. This is the direct regression
|
||||
test for the bug described in this plan's Background section:
|
||||
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
def test_get_document_references_only_queries_referenced_documents(
|
||||
django_assert_num_queries,
|
||||
) -> None:
|
||||
"""Building references must not hydrate every document the caller is
|
||||
permitted to see -- only the (<= CHAT_RETRIEVER_TOP_K) documents that
|
||||
the retriever actually returned nodes for.
|
||||
"""
|
||||
referenced = DocumentFactory.create(title="Referenced Document")
|
||||
# Many more documents are "accessible" but never referenced by a node.
|
||||
DocumentFactory.create_batch(200)
|
||||
|
||||
documents = Document.objects.all()
|
||||
top_nodes = [
|
||||
MagicMock(metadata={"document_id": str(referenced.pk), "title": "Referenced Document"}),
|
||||
]
|
||||
|
||||
# One query: `documents.filter(pk__in=candidate_ids)` for the single
|
||||
# referenced id. No query should scale with the 200 unreferenced documents.
|
||||
with django_assert_num_queries(1):
|
||||
references = chat._get_document_references(documents, top_nodes)
|
||||
|
||||
assert references == [{"id": referenced.pk, "title": "Referenced Document"}]
|
||||
```
|
||||
|
||||
`django_assert_num_queries` is a `pytest-django` fixture available automatically, no new
|
||||
dependency needed.
|
||||
|
||||
- [ ] **Step 4: Run the test file and confirm it fails for the expected reason**
|
||||
|
||||
Run: `uv run pytest --override-ini="addopts=" src/paperless_ai/tests/test_chat.py -v`
|
||||
|
||||
Expected: multiple failures (`AttributeError`, e.g. `'list' object has no attribute 'exists'`,
|
||||
or logic mismatches), because `_stream_chat_with_documents` / `_get_document_references` still
|
||||
expect a `list[Document]`. Read the actual pytest output before proceeding -- do not assume the
|
||||
failure mode in advance.
|
||||
|
||||
Do not proceed to Task 2 until you have read the actual failure output and confirmed the tests
|
||||
are red for a real reason (signature/behavior mismatch), not a typo in the test itself.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rework `chat.py` to defer hydration and query only referenced documents (GREEN)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/paperless_ai/chat.py`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `documents: QuerySet[Document]` (passed in by `views.py`, updated in Task 3).
|
||||
- Produces: `stream_chat_with_documents(query_str: str, documents: QuerySet[Document], output_language: str | None = None)` -- same external name/params, new `documents` type. `_get_document_references(documents: QuerySet[Document], top_nodes: list) -> list[dict[str, int | str]]` -- same name/return type, new parameter type and internal behavior (queries only referenced ids).
|
||||
|
||||
- [ ] **Step 1: Add the `QuerySet` import and update type hints**
|
||||
|
||||
```python
|
||||
from django.db.models import QuerySet
|
||||
```
|
||||
|
||||
(`Document` is already imported at the top of `chat.py`.) Update the signatures of
|
||||
`stream_chat_with_documents`, `_stream_chat_with_documents`, and `_get_document_references` to
|
||||
take `documents: QuerySet[Document]` instead of `documents: list[Document]`. Keep
|
||||
`output_language: str | None = None` as-is on the two functions that already carry it.
|
||||
|
||||
- [ ] **Step 2: Replace the full-materialization emptiness check**
|
||||
|
||||
In `_stream_chat_with_documents`:
|
||||
|
||||
```python
|
||||
def _stream_chat_with_documents(
|
||||
query_str: str,
|
||||
documents: QuerySet[Document],
|
||||
output_language: str | None = None,
|
||||
):
|
||||
if not documents.exists():
|
||||
yield CHAT_NO_CONTENT_MESSAGE
|
||||
return
|
||||
```
|
||||
|
||||
(`documents.exists()` issues a lightweight existence check; for `Document.objects.none()` it
|
||||
short-circuits without hitting the database at all.)
|
||||
|
||||
- [ ] **Step 3: Replace the filter-building line to use ids only**
|
||||
|
||||
```python
|
||||
config = AIConfig()
|
||||
filters = _document_id_filters(
|
||||
str(pk) for pk in documents.values_list("pk", flat=True)
|
||||
)
|
||||
```
|
||||
|
||||
This still touches every accessible document's id (inherent to scoping the vector-store `IN`
|
||||
filter to the permitted set -- see Background, point 3, which remains out of scope), but no
|
||||
longer loads full `Document` rows -- just a flat list of integers.
|
||||
|
||||
- [ ] **Step 4: Rework `_get_document_references` to hydrate only referenced documents**
|
||||
|
||||
```python
|
||||
def _get_document_references(
|
||||
documents: QuerySet[Document],
|
||||
top_nodes: list,
|
||||
) -> list[dict[str, int | str]]:
|
||||
candidate_ids: set[int] = set()
|
||||
for node in top_nodes:
|
||||
try:
|
||||
candidate_ids.add(int(node.metadata["document_id"]))
|
||||
except (KeyError, TypeError, ValueError): # pragma: no cover
|
||||
continue
|
||||
|
||||
if not candidate_ids:
|
||||
return []
|
||||
|
||||
allowed_documents = {
|
||||
doc.pk: doc for doc in documents.filter(pk__in=candidate_ids)
|
||||
}
|
||||
|
||||
references: list[dict[str, int | str]] = []
|
||||
seen_document_ids: set[int] = set()
|
||||
|
||||
for node in top_nodes:
|
||||
try:
|
||||
document_id = int(node.metadata["document_id"])
|
||||
except (KeyError, TypeError, ValueError): # pragma: no cover
|
||||
continue
|
||||
|
||||
if document_id in seen_document_ids or document_id not in allowed_documents:
|
||||
continue
|
||||
|
||||
seen_document_ids.add(document_id)
|
||||
document = allowed_documents[document_id]
|
||||
references.append(
|
||||
_build_document_reference(document, node.metadata.get("title")),
|
||||
)
|
||||
|
||||
if len(references) >= MAX_CHAT_REFERENCES: # pragma: no cover
|
||||
break
|
||||
|
||||
return references
|
||||
```
|
||||
|
||||
`documents.filter(pk__in=candidate_ids)` re-applies the permission scoping (`documents` is
|
||||
still the caller's permission-scoped queryset) but now against at most `CHAT_RETRIEVER_TOP_K`
|
||||
(5) ids instead of the whole accessible set -- this is the permission check the original code
|
||||
performed, just run after retrieval instead of before, and bounded instead of unbounded.
|
||||
|
||||
- [ ] **Step 5: Run the chat test file and confirm it passes**
|
||||
|
||||
Run: `uv run pytest --override-ini="addopts=" src/paperless_ai/tests/test_chat.py -v`
|
||||
|
||||
Expected: all tests pass, including `test_get_document_references_only_queries_referenced_documents`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/paperless_ai/chat.py src/paperless_ai/tests/test_chat.py
|
||||
git commit -m "Fix: bound chat document reference lookup to retrieved nodes instead of whole accessible library"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update `ChatStreamingView.post` to pass a QuerySet for the single-document branch
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/documents/views.py` (`ChatStreamingView.post` -- re-locate with `rg -n "class ChatStreamingView" src/documents/views.py` before editing, in case other changes shifted it)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `stream_chat_with_documents(query_str, documents: QuerySet[Document], output_language)` (Task 2's new signature).
|
||||
- Produces: nothing new for later tasks.
|
||||
|
||||
- [ ] **Step 1: Build a QuerySet in the single-document branch**
|
||||
|
||||
Change only this one line inside `post`:
|
||||
|
||||
```python
|
||||
documents = Document.objects.filter(pk=document.pk)
|
||||
```
|
||||
|
||||
in place of the current `documents = [document]`. Everything else in `post` (the
|
||||
`has_perms_owner_aware` check against the fully-hydrated `document`, the `else` branch using
|
||||
`permitted_document_ids`, the `output_language` lookup, the `StreamingHttpResponse`
|
||||
construction) is unchanged -- it already passes a `QuerySet` in the `else` branch; Task 2's
|
||||
changes inside `chat.py` are what stop that queryset from being force-materialized downstream.
|
||||
|
||||
- [ ] **Step 2: Run the view tests**
|
||||
|
||||
Three test locations cover this view (re-check with
|
||||
`rg -n "ChatStreamingView|/api/chat|stream_chat_with_documents" src/documents/tests/*.py` if
|
||||
more time has passed since this plan was written):
|
||||
|
||||
1. `src/documents/tests/test_views.py`, class `TestAIChatStreamingView` -- patches
|
||||
`stream_chat_with_documents` entirely, doesn't inspect `documents`' type.
|
||||
2. `src/documents/tests/test_api_chat.py`, class `TestChatStreamingViewInputValidation` --
|
||||
input-validation only, doesn't reach `documents` construction.
|
||||
3. `src/documents/tests/test_permission_filtering_security.py`, class
|
||||
`TestAiChatAllDocumentsPermissionBoundary`, test
|
||||
`test_chat_all_documents_excludes_unshared_document` -- **this is the one that actually
|
||||
matters for this change**: it asserts on `kwargs["documents"]` from the mocked
|
||||
`stream_chat_with_documents` call (`{doc.pk for doc in kwargs["documents"]}`), pinning the
|
||||
permission-scoping behavior this plan touches. Read this test specifically before/after the
|
||||
change, not just via a blind `-k chat` filter -- iterating a `QuerySet` with a set
|
||||
comprehension works the same as iterating a `list`, so it should keep passing unchanged, but
|
||||
confirm rather than assume.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest --override-ini="addopts=" src/documents/tests/ -v -k chat
|
||||
uv run pytest --override-ini="addopts=" src/documents/tests/test_permission_filtering_security.py -v -k AllDocumentsPermissionBoundary
|
||||
```
|
||||
|
||||
Expected: all pass unchanged.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/documents/views.py
|
||||
git commit -m "Fix: pass single-document chat queries as a QuerySet instead of a materialized list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Full verification
|
||||
|
||||
**Files:** none (verification only, except Step 0's benchmark re-run reuses Task 0's file)
|
||||
|
||||
- [ ] **Step 0: Re-run Task 0's benchmark against the fixed code and compare**
|
||||
|
||||
Re-run the exact same benchmark harness from Task 0 (same library sizes, same measured
|
||||
functions) now that Task 2's fix has landed. This is the actual proof the fix works, not just
|
||||
that tests pass -- prove the improvement, don't assume it. Expect:
|
||||
|
||||
- `_get_document_references` query count/time to become roughly constant (bounded by
|
||||
`CHAT_RETRIEVER_TOP_K = 5`) instead of scaling with library size.
|
||||
- `_document_id_filters`' cost is unchanged in shape (Task 2 only avoids hydrating full
|
||||
`Document` rows there, via `.values_list("pk", flat=True)`; it still touches every accessible
|
||||
id -- see Background, point 3, still out of scope) but should show reduced wall time/memory
|
||||
from not loading full rows.
|
||||
|
||||
Record the before/after comparison (e.g. as a small table: library size, before query
|
||||
count/time, after query count/time) back into Task 0's section of this plan. If the numbers do
|
||||
NOT show the expected improvement, stop and treat that as a signal the fix is incomplete or
|
||||
wrong before proceeding to the rest of this task's steps.
|
||||
|
||||
- [ ] **Step 1: Run the full `paperless_ai` and relevant `documents` test suites**
|
||||
|
||||
```bash
|
||||
uv run pytest --override-ini="addopts=" src/paperless_ai/tests/ -v
|
||||
uv run pytest --override-ini="addopts=" src/documents/tests/ -v -k chat
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 2: Run ruff, and mypy/pyrefly via prek, to confirm no new baseline violations or lint issues**
|
||||
|
||||
```bash
|
||||
uv run ruff check src/paperless_ai/chat.py src/documents/views.py
|
||||
uv run ruff format --check src/paperless_ai/chat.py src/documents/views.py
|
||||
uv run prek run --all-files
|
||||
```
|
||||
|
||||
Expected: clean, and no new violations beyond `.mypy-baseline.txt` / `.pyrefly-baseline.json`.
|
||||
|
||||
- [ ] **Step 3: Confirm both in-scope fixes from Background are addressed**
|
||||
|
||||
Point 1 (don't materialize full `Document` rows for the filter step) -- addressed by Task 2 Step 3.
|
||||
Point 2 (permission-check only `top_nodes`, bounded by `CHAT_RETRIEVER_TOP_K`) -- addressed by Task 2 Step 4.
|
||||
Point 3 (whether the vector-store `IN (...)` filter itself is a KNN scaling concern) remains
|
||||
explicitly out of scope for this plan -- if it needs tracking as future work, open a fresh
|
||||
issue/note for it rather than reviving old diagnosis documents.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** both in-scope points from Background ("don't materialize full `Document` rows for the filter step" and "permission-check only `top_nodes`, bounded by `CHAT_RETRIEVER_TOP_K`") are implemented in Task 2. The vector-store `IN` filter scaling question is explicitly out of scope and not silently dropped -- it's called out in Background, Global Constraints, and Task 4 Step 3.
|
||||
- **Placeholder scan:** no TBD/TODO markers; every step has literal code.
|
||||
- **Type consistency:** `documents: QuerySet[Document]` is consistent across `stream_chat_with_documents`, `_stream_chat_with_documents`, `_get_document_references`, and both call sites in `views.py`. `_build_document_reference`'s signature is unchanged (still takes a hydrated `Document`). `output_language` threading is preserved unchanged throughout.
|
||||
- **Self-contained:** this plan does not depend on any other document, branch, or worktree existing -- all context needed to execute it (bug diagnosis, current code, fix design) is inlined above.
|
||||
@@ -99,8 +99,6 @@ Think of versions as **file history** for a document.
|
||||
- By default, search and document content use the latest version.
|
||||
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
||||
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
||||
- From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own.
|
||||
- From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document.
|
||||
|
||||
### Management Lists
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ dependencies = [
|
||||
"django-soft-delete~=1.0.18",
|
||||
"django-treenode>=0.24",
|
||||
"djangorestframework~=3.16",
|
||||
"djangorestframework-guardian~=0.4.0",
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar~=2026.7.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{message}}</p>
|
||||
<div class="form-group">
|
||||
<span class="form-label d-inline-block" i18n>Versions:</span>
|
||||
<ul class="list-group">
|
||||
@for (documentID of versionDocumentIDs(); track documentID) {
|
||||
@let document = getDocument(documentID);
|
||||
@if (document) {
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<div class="d-flex flex-column">
|
||||
<div>
|
||||
@if (document.correspondent) {
|
||||
<b>{{document.correspondent | correspondentName | async}}: </b>
|
||||
}{{document.title}}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{document.created | customDate:'mediumDate'}}
|
||||
@if (document.page_count) {
|
||||
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<label class="form-label" for="rootDocumentID" i18n>Root document:</label>
|
||||
<select id="rootDocumentID" class="form-select" [ngModel]="rootDocumentID()" (ngModelChange)="rootDocumentID.set($event)">
|
||||
@for (document of documents(); track document.id) {
|
||||
<option [ngValue]="document.id">{{document.title}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of } from 'rxjs'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
|
||||
|
||||
describe('MergeAsVersionsConfirmDialogComponent', () => {
|
||||
let component: MergeAsVersionsConfirmDialogComponent
|
||||
let fixture: ComponentFixture<MergeAsVersionsConfirmDialogComponent>
|
||||
let documentService: DocumentService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MergeAsVersionsConfirmDialogComponent],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(MergeAsVersionsConfirmDialogComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should fetch selected documents', () => {
|
||||
const documents = [
|
||||
{ id: 1, title: 'Document 1' },
|
||||
{ id: 2, title: 'Document 2' },
|
||||
]
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
all: [1, 2],
|
||||
count: 2,
|
||||
results: documents,
|
||||
})
|
||||
)
|
||||
component.documentIDs.set([1, 2])
|
||||
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.documents()).toEqual(documents)
|
||||
expect(documentService.getFew).toHaveBeenCalledWith([1, 2])
|
||||
})
|
||||
|
||||
it('should exclude the root from the draggable documents', () => {
|
||||
component.documentIDs.set([1, 2, 3])
|
||||
component.rootDocumentID.set(2)
|
||||
|
||||
expect(component.versionDocumentIDs()).toEqual([1, 3])
|
||||
})
|
||||
})
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-merge-as-versions-confirm-dialog',
|
||||
templateUrl: './merge-as-versions-confirm-dialog.component.html',
|
||||
imports: [AsyncPipe, CorrespondentNamePipe, CustomDatePipe, FormsModule],
|
||||
})
|
||||
export class MergeAsVersionsConfirmDialogComponent
|
||||
extends ConfirmDialogComponent
|
||||
implements OnInit
|
||||
{
|
||||
private readonly documentService = inject(DocumentService)
|
||||
|
||||
readonly documentIDs = signal<number[]>([])
|
||||
readonly documents = signal<Document[]>([])
|
||||
readonly rootDocumentID = signal(-1)
|
||||
readonly versionDocumentIDs = computed(() =>
|
||||
this.documentIDs().filter(
|
||||
(documentID) => documentID !== this.rootDocumentID()
|
||||
)
|
||||
)
|
||||
|
||||
ngOnInit() {
|
||||
this.documentService
|
||||
.getFew(this.documentIDs())
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((response) => this.documents.set(response.results))
|
||||
}
|
||||
|
||||
getDocument(documentID: number): Document {
|
||||
return this.documents().find((document) => document.id === documentID)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||
<select id="metadataDocumentID" class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||
@for (document of documents(); track document.id) {
|
||||
<option [ngValue]="document.id">{{document.title}}</option>
|
||||
|
||||
@@ -151,6 +151,13 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
& section {
|
||||
position: absolute;
|
||||
text-align: initial;
|
||||
box-sizing: border-box;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
& .annotationTextContent {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
AnnotationMode,
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
PDFDocumentLoadingTask,
|
||||
@@ -221,6 +222,7 @@ export class PngxPdfViewerComponent
|
||||
linkService: this.linkService,
|
||||
findController: this.findController,
|
||||
textLayerMode,
|
||||
annotationMode: AnnotationMode.ENABLE,
|
||||
enableSelectionRendering: false,
|
||||
removePageBorders: true,
|
||||
}
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" i18n>Add existing document as version</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pngx-input-document-link
|
||||
[(ngModel)]="selectedDocumentIDs"
|
||||
[parentDocumentID]="rootDocumentID"
|
||||
[minimal]="true"
|
||||
placeholder="Search for a document"
|
||||
i18n-placeholder
|
||||
></pngx-input-document-link>
|
||||
<div class="form-text mt-2" i18n>Select one document to add as a version.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled || selectedDocumentIDs.length !== 1" i18n>Add version</button>
|
||||
</div>
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog.component'
|
||||
|
||||
describe('AddExistingDocumentVersionDialogComponent', () => {
|
||||
let component: AddExistingDocumentVersionDialogComponent
|
||||
let fixture: ComponentFixture<AddExistingDocumentVersionDialogComponent>
|
||||
let activeModal: jest.Mocked<Pick<NgbActiveModal, 'dismiss'>>
|
||||
|
||||
beforeEach(async () => {
|
||||
activeModal = { dismiss: jest.fn() }
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AddExistingDocumentVersionDialogComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: NgbActiveModal,
|
||||
useValue: activeModal,
|
||||
},
|
||||
{
|
||||
provide: DocumentService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(AddExistingDocumentVersionDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
component.rootDocumentID = 3
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should emit the single selected document', () => {
|
||||
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||
component.selectedDocumentIDs = [20]
|
||||
|
||||
component.confirm()
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(20)
|
||||
})
|
||||
|
||||
it('should require exactly one selected document', () => {
|
||||
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||
component.selectedDocumentIDs = [20, 21]
|
||||
|
||||
component.confirm()
|
||||
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should dismiss on cancel', () => {
|
||||
component.cancel()
|
||||
|
||||
expect(activeModal.dismiss).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-add-existing-document-version-dialog',
|
||||
templateUrl: './add-existing-document-version-dialog.component.html',
|
||||
imports: [DocumentLinkComponent, FormsModule],
|
||||
})
|
||||
export class AddExistingDocumentVersionDialogComponent {
|
||||
private readonly activeModal = inject(NgbActiveModal)
|
||||
|
||||
@Input() rootDocumentID: number
|
||||
@Output() confirmClicked = new EventEmitter<number>()
|
||||
|
||||
selectedDocumentIDs: number[] = []
|
||||
buttonsEnabled = true
|
||||
|
||||
confirm(): void {
|
||||
if (this.selectedDocumentIDs.length !== 1) return
|
||||
this.confirmClicked.emit(this.selectedDocumentIDs[0])
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.activeModal.dismiss()
|
||||
}
|
||||
}
|
||||
+7
-20
@@ -24,26 +24,13 @@
|
||||
class="visually-hidden"
|
||||
(change)="onVersionFileSelected($event)"
|
||||
/>
|
||||
<div class="btn-group btn-group-sm w-100">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary w-100"
|
||||
(click)="versionFileInput.click()"
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
title="Upload a new version"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Upload</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary w-100"
|
||||
(click)="addExistingDocumentAsVersion()"
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
title="Use an existing document"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="file-earmark"></i-bs><span class="ps-1" i18n>Existing</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary w-100"
|
||||
(click)="versionFileInput.click()"
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
>
|
||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
||||
</button>
|
||||
} @else {
|
||||
@switch (versionUploadState()) {
|
||||
@case (UploadState.Uploading) {
|
||||
|
||||
+1
-52
@@ -1,7 +1,6 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { SimpleChange } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
@@ -20,17 +19,12 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
let documentService: jest.Mocked<
|
||||
Pick<
|
||||
DocumentService,
|
||||
| 'deleteVersion'
|
||||
| 'getVersions'
|
||||
| 'mergeDocumentsAsVersions'
|
||||
| 'uploadVersion'
|
||||
| 'updateVersionLabel'
|
||||
'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||
|
||||
beforeEach(async () => {
|
||||
finished$ = new Subject<{ taskId: string }>()
|
||||
@@ -38,11 +32,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
documentService = {
|
||||
deleteVersion: jest.fn(),
|
||||
getVersions: jest.fn(),
|
||||
mergeDocumentsAsVersions: jest.fn(),
|
||||
uploadVersion: jest.fn(),
|
||||
updateVersionLabel: jest.fn(),
|
||||
}
|
||||
modalService = { open: jest.fn() }
|
||||
toastService = {
|
||||
showError: jest.fn(),
|
||||
showInfo: jest.fn(),
|
||||
@@ -69,10 +61,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
provide: ToastService,
|
||||
useValue: toastService,
|
||||
},
|
||||
{
|
||||
provide: NgbModal,
|
||||
useValue: modalService,
|
||||
},
|
||||
{
|
||||
provide: WebsocketStatusService,
|
||||
useValue: {
|
||||
@@ -335,43 +323,4 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
expect(component.editingVersionId).toBeNull()
|
||||
expect(component.versionLabelDraft).toEqual('')
|
||||
})
|
||||
|
||||
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
|
||||
const confirmClicked = new Subject<number>()
|
||||
const modal = {
|
||||
componentInstance: {
|
||||
rootDocumentID: null,
|
||||
buttonsEnabled: true,
|
||||
confirmClicked,
|
||||
},
|
||||
close: jest.fn(),
|
||||
}
|
||||
modalService.open.mockReturnValue(modal as any)
|
||||
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
|
||||
const versions: DocumentVersionInfo[] = [
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
{ id: 20, is_root: false, checksum: 'cccc' },
|
||||
]
|
||||
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
|
||||
component.newVersionLabel = ' Imported '
|
||||
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
||||
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
||||
|
||||
component.addExistingDocumentAsVersion()
|
||||
expect(modal.componentInstance.rootDocumentID).toEqual(3)
|
||||
confirmClicked.next(20)
|
||||
|
||||
expect(documentService.mergeDocumentsAsVersions).toHaveBeenCalledWith(
|
||||
[3, 20],
|
||||
3,
|
||||
'Imported'
|
||||
)
|
||||
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
||||
expect(documentService.getVersions).toHaveBeenCalledWith(3)
|
||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
|
||||
expect(component.newVersionLabel).toEqual('')
|
||||
expect(modal.close).toHaveBeenCalled()
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+1
-53
@@ -11,7 +11,7 @@ import {
|
||||
SimpleChanges,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { merge, of, Subject } from 'rxjs'
|
||||
import {
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
WebsocketStatusService,
|
||||
} from 'src/app/services/websocket-status.service'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-version-dropdown',
|
||||
@@ -70,7 +69,6 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
private readonly documentsService = inject(DocumentService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly documentChange$ = new Subject<void>()
|
||||
|
||||
@@ -280,56 +278,6 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
})
|
||||
}
|
||||
|
||||
addExistingDocumentAsVersion(): void {
|
||||
const modal = this.modalService.open(
|
||||
AddExistingDocumentVersionDialogComponent,
|
||||
{ backdrop: 'static' }
|
||||
)
|
||||
const dialog =
|
||||
modal.componentInstance as AddExistingDocumentVersionDialogComponent
|
||||
dialog.rootDocumentID = this.documentId
|
||||
dialog.confirmClicked
|
||||
.pipe(takeUntil(this.destroy$), takeUntil(this.documentChange$))
|
||||
.subscribe((existingDocumentID) => {
|
||||
dialog.buttonsEnabled = false
|
||||
const versionLabel = this.newVersionLabel?.trim()
|
||||
this.documentsService
|
||||
.mergeDocumentsAsVersions(
|
||||
[this.documentId, existingDocumentID],
|
||||
this.documentId,
|
||||
versionLabel
|
||||
)
|
||||
.pipe(
|
||||
switchMap(() => this.documentsService.getVersions(this.documentId)),
|
||||
first(),
|
||||
finalize(() => (dialog.buttonsEnabled = true)),
|
||||
takeUntil(this.destroy$),
|
||||
takeUntil(this.documentChange$)
|
||||
)
|
||||
.subscribe({
|
||||
next: (document) => {
|
||||
if (document?.versions) {
|
||||
this.versionsUpdated.emit(document.versions)
|
||||
this.versionSelected.emit(
|
||||
Math.max(...document.versions.map((version) => version.id))
|
||||
)
|
||||
}
|
||||
this.newVersionLabel = ''
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
$localize`Existing document added as a version.`
|
||||
)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error adding existing document as a version`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
clearVersionUploadStatus(): void {
|
||||
this.versionUploadState.set(UploadState.Idle)
|
||||
this.versionUploadError.set(null)
|
||||
|
||||
@@ -95,9 +95,6 @@
|
||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
|
||||
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || list.allSelected || list.selectedCount < 2">
|
||||
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1248,52 +1248,6 @@ describe('BulkEditorComponent', () => {
|
||||
expect(documentListViewService.selected.size).toEqual(0)
|
||||
})
|
||||
|
||||
it('should support merging documents as versions', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
all: [3, 4],
|
||||
count: 2,
|
||||
results: [
|
||||
{ id: 3, title: 'Document 3' },
|
||||
{ id: 4, title: 'Document 4' },
|
||||
],
|
||||
})
|
||||
)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserOwnsObject')
|
||||
.mockReturnValue(true)
|
||||
const mergeAsVersionsSpy = jest
|
||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||
.mockReturnValue(of(true))
|
||||
fixture.detectChanges()
|
||||
|
||||
component.mergeSelectedAsVersions()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.rootDocumentID.set(4)
|
||||
modal.componentInstance.confirm()
|
||||
|
||||
expect(mergeAsVersionsSpy).toHaveBeenCalledWith([3, 4], 4)
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
)
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
)
|
||||
expect(documentListViewService.selected.size).toEqual(0)
|
||||
})
|
||||
|
||||
it('should support bulk download with archive, originals or both and file formatting', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
|
||||
@@ -50,7 +50,6 @@ import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
@@ -1003,34 +1002,6 @@ export class BulkEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
mergeSelectedAsVersions() {
|
||||
let modal = this.modalService.open(MergeAsVersionsConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const mergeDialog =
|
||||
modal.componentInstance as MergeAsVersionsConfirmDialogComponent
|
||||
const documentIDs = Array.from(this.list.selected)
|
||||
mergeDialog.title = $localize`Merge as versions`
|
||||
mergeDialog.message = $localize`The selected documents will become versions of the root document.`
|
||||
mergeDialog.btnCaption = $localize`Proceed`
|
||||
mergeDialog.documentIDs.set(documentIDs)
|
||||
mergeDialog.rootDocumentID.set(documentIDs[0])
|
||||
mergeDialog.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
mergeDialog.buttonsEnabled = false
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.mergeDocumentsAsVersions(
|
||||
mergeDialog.documentIDs(),
|
||||
mergeDialog.rootDocumentID()
|
||||
),
|
||||
{ deleteOriginals: true }
|
||||
)
|
||||
this.toastService.showInfo($localize`Documents merged as versions.`)
|
||||
})
|
||||
}
|
||||
|
||||
public setCustomFieldValues(changedCustomFields: ChangedItems) {
|
||||
const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@
|
||||
@if (depth > 0) {
|
||||
<div class="indicator"></div>
|
||||
}
|
||||
<button class="btn btn-link ms-0 ps-0 text-start" style="user-select: text;" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||
<button class="btn btn-link ms-0 ps-0 text-start" style="user-select: text;" [disabled]="!userCanEdit(object)" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||
</td>
|
||||
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||
<td>{{ getDocumentCount(object) }}</td>
|
||||
|
||||
@@ -316,34 +316,6 @@ describe(`DocumentService`, () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for merging documents as versions', () => {
|
||||
const ids = [1, 2, 3]
|
||||
subscription = service.mergeDocumentsAsVersions(ids, 2).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/merge_as_versions/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
root_document_id: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('should include an optional label when merging one document as a version', () => {
|
||||
const ids = [1, 2]
|
||||
subscription = service
|
||||
.mergeDocumentsAsVersions(ids, 2, 'Imported')
|
||||
.subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/merge_as_versions/`
|
||||
)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
root_document_id: 2,
|
||||
version_label: 'Imported',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for edit pdf', () => {
|
||||
const ids = [1]
|
||||
const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] }
|
||||
|
||||
@@ -374,18 +374,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
})
|
||||
}
|
||||
|
||||
mergeDocumentsAsVersions(
|
||||
ids: number[],
|
||||
rootDocumentId: number,
|
||||
versionLabel?: string
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
|
||||
documents: ids,
|
||||
root_document_id: rootDocumentId,
|
||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
||||
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
|
||||
documents: ids,
|
||||
|
||||
@@ -101,7 +101,6 @@ import {
|
||||
house,
|
||||
infoCircle,
|
||||
journals,
|
||||
journalBookmarkFill,
|
||||
link,
|
||||
listNested,
|
||||
listTask,
|
||||
@@ -324,7 +323,6 @@ const icons = {
|
||||
hddStack,
|
||||
house,
|
||||
infoCircle,
|
||||
journalBookmarkFill,
|
||||
journals,
|
||||
link,
|
||||
listNested,
|
||||
|
||||
@@ -19,6 +19,13 @@ export const GlobalWorkerOptions = {
|
||||
workerSrc: '',
|
||||
}
|
||||
|
||||
export const AnnotationMode = {
|
||||
DISABLE: 0,
|
||||
ENABLE: 1,
|
||||
ENABLE_FORMS: 2,
|
||||
ENABLE_STORAGE: 3,
|
||||
}
|
||||
|
||||
export const getDocument = (_src: unknown): PDFDocumentLoadingTask => {
|
||||
return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy()))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ from celery import group
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -31,7 +30,6 @@ from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import remove_document_from_index
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_root_document
|
||||
@@ -614,77 +612,6 @@ def merge(
|
||||
return "OK"
|
||||
|
||||
|
||||
def merge_as_versions(
|
||||
doc_ids: list[int],
|
||||
*,
|
||||
root_document_id: int,
|
||||
version_label: str | None = None,
|
||||
) -> Literal["OK"]:
|
||||
with transaction.atomic():
|
||||
documents = list(
|
||||
Document.objects.select_for_update().filter(id__in=doc_ids),
|
||||
)
|
||||
documents_by_id = {document.id: document for document in documents}
|
||||
|
||||
if len(documents) != len(doc_ids):
|
||||
raise ValueError("Some documents do not exist or were specified twice.")
|
||||
if root_document_id not in documents_by_id:
|
||||
raise ValueError("The root document must be selected.")
|
||||
if any(document.root_document_id is not None for document in documents):
|
||||
raise ValueError("Only top-level documents can be merged as versions.")
|
||||
|
||||
source_ids = sorted(doc_id for doc_id in doc_ids if doc_id != root_document_id)
|
||||
if version_label is not None and len(source_ids) != 1:
|
||||
raise ValueError(
|
||||
"A version label can only be set when merging one source document.",
|
||||
)
|
||||
if Document.objects.filter(root_document_id__in=source_ids).exists():
|
||||
raise ValueError(
|
||||
"Documents with existing versions cannot be merged into another document.",
|
||||
)
|
||||
|
||||
root_document = documents_by_id[root_document_id]
|
||||
next_version_index = (
|
||||
Document.global_objects.filter(
|
||||
root_document_id=root_document_id,
|
||||
).aggregate(max_index=Max("version_index"))["max_index"]
|
||||
or 0
|
||||
)
|
||||
|
||||
for source_id in source_ids:
|
||||
source_document = documents_by_id[source_id]
|
||||
next_version_index += 1
|
||||
source_document.root_document = root_document
|
||||
source_document.version_index = next_version_index
|
||||
update_fields = [
|
||||
"root_document",
|
||||
"version_index",
|
||||
"archive_serial_number",
|
||||
]
|
||||
if version_label is not None:
|
||||
source_document.version_label = version_label
|
||||
update_fields.append("version_label")
|
||||
source_document.archive_serial_number = None
|
||||
source_document.save(update_fields=update_fields)
|
||||
|
||||
root_document.modified = timezone.now()
|
||||
root_document.save(update_fields=["modified"])
|
||||
|
||||
for source_id in source_ids:
|
||||
remove_document_from_index.apply_async(args=[source_id])
|
||||
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": [root_document_id]},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
|
||||
# And as far as the frontend is concerned, they're deleted
|
||||
status_mgr = DocumentsStatusManager()
|
||||
status_mgr.send_documents_deleted(source_ids)
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
def split(
|
||||
doc_ids: list[int],
|
||||
pages: list[list[int]],
|
||||
|
||||
+24
-49
@@ -39,7 +39,6 @@ from guardian.utils import get_user_obj_perms_model
|
||||
from rest_framework import serializers
|
||||
from rest_framework.filters import BaseFilterBackend
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
@@ -51,7 +50,7 @@ from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -1028,59 +1027,35 @@ class PaperlessTaskFilterSet(FilterSet):
|
||||
return queryset.exclude(status__in=PaperlessTask.COMPLETE_STATUSES)
|
||||
|
||||
|
||||
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
|
||||
class PermittedObjectsFilter(BaseFilterBackend):
|
||||
"""
|
||||
A filter backend that limits results to those where the requesting user
|
||||
has read object level permissions, owns the objects, or objects without
|
||||
an owner (for backwards compat)
|
||||
Filters a queryset down to objects the requesting user owns, are
|
||||
unowned, or (when ``include_granted`` is True) has an explicit
|
||||
user/group guardian permission on. Backed by ``permitted_object_ids``
|
||||
-- a single ``id__in`` subquery, not a join -- so it can't produce
|
||||
duplicate rows even when the base queryset already carries independent
|
||||
joins (e.g. multi-value ``tags__id__all`` filtering), and stays
|
||||
index-friendly at scale instead of falling back to guardian's
|
||||
varchar-cast join.
|
||||
|
||||
Set ``include_granted = False`` on a subclass for endpoints that
|
||||
intentionally only show owned/unowned objects regardless of explicit
|
||||
shares (e.g. ``TrashView``).
|
||||
"""
|
||||
|
||||
include_granted: bool = True
|
||||
perm_codename: str | None = None
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
if request.user.is_superuser:
|
||||
return queryset
|
||||
objects_with_perms = super().filter_queryset(request, queryset, view)
|
||||
objects_owned = queryset.filter(owner=request.user)
|
||||
objects_unowned = queryset.filter(owner__isnull=True)
|
||||
return objects_with_perms | objects_owned | objects_unowned
|
||||
|
||||
|
||||
class DocumentPermissionsFilter(BaseFilterBackend):
|
||||
"""
|
||||
A filter backend limiting Document results to those the requesting user
|
||||
owns, are unowned, or has explicit (user- or group-level) view
|
||||
permission on.
|
||||
|
||||
Unlike ``ObjectOwnedOrGrantedPermissionsFilter``, this does not build an
|
||||
``objects_with_perms | objects_owned | objects_unowned`` union of
|
||||
querysets derived from the same base queryset. When that base queryset
|
||||
already carries independent joins on a multi-valued relation (e.g. two
|
||||
separate joins from ``tags__id__all`` filtering on two tags), each
|
||||
OR-ed branch can end up pairing those joins' aliases differently,
|
||||
letting more than one row out of the join's cross product satisfy the
|
||||
combined WHERE -- returning the same document more than once. Filtering
|
||||
via a single ``id__in`` against ``permitted_document_ids`` (a plain
|
||||
subquery, not a join) sidesteps that entirely and is also cheaper than
|
||||
guardian's join-based permission check.
|
||||
"""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
if request.user.is_superuser:
|
||||
return queryset
|
||||
return queryset.filter(id__in=permitted_document_ids(request.user))
|
||||
|
||||
|
||||
class ObjectOwnedPermissionsFilter(ObjectPermissionsFilter):
|
||||
"""
|
||||
A filter backend that limits results to those where the requesting user
|
||||
owns the objects or objects without an owner (for backwards compat)
|
||||
"""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
if request.user.is_superuser:
|
||||
return queryset
|
||||
objects_owned = queryset.filter(owner=request.user)
|
||||
objects_unowned = queryset.filter(owner__isnull=True)
|
||||
return objects_owned | objects_unowned
|
||||
if not self.include_granted:
|
||||
return queryset.filter(Q(owner=request.user) | Q(owner__isnull=True))
|
||||
model = queryset.model
|
||||
perm = self.perm_codename or f"view_{model._meta.model_name}"
|
||||
return queryset.filter(
|
||||
id__in=permitted_object_ids(request.user, model, perm),
|
||||
)
|
||||
|
||||
|
||||
class DocumentsOrderingFilter(OrderingFilter):
|
||||
|
||||
+10
-14
@@ -19,7 +19,7 @@ from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.regex import safe_regex_search
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -55,10 +55,8 @@ def match_correspondents(document: Document, classifier: DocumentClassifier, use
|
||||
user = document.owner
|
||||
|
||||
if user is not None:
|
||||
correspondents = get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_correspondent",
|
||||
Correspondent,
|
||||
correspondents = Correspondent.objects.filter(
|
||||
id__in=permitted_object_ids(user, Correspondent, "view_correspondent"),
|
||||
)
|
||||
else:
|
||||
correspondents = Correspondent.objects.all()
|
||||
@@ -86,10 +84,8 @@ def match_document_types(document: Document, classifier: DocumentClassifier, use
|
||||
user = document.owner
|
||||
|
||||
if user is not None:
|
||||
document_types = get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_documenttype",
|
||||
DocumentType,
|
||||
document_types = DocumentType.objects.filter(
|
||||
id__in=permitted_object_ids(user, DocumentType, "view_documenttype"),
|
||||
)
|
||||
else:
|
||||
document_types = DocumentType.objects.all()
|
||||
@@ -116,7 +112,9 @@ def match_tags(document: Document, classifier: DocumentClassifier, user=None):
|
||||
user = document.owner
|
||||
|
||||
if user is not None:
|
||||
tags = get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
||||
tags = Tag.objects.filter(
|
||||
id__in=permitted_object_ids(user, Tag, "view_tag"),
|
||||
)
|
||||
else:
|
||||
tags = Tag.objects.all()
|
||||
|
||||
@@ -145,10 +143,8 @@ def match_storage_paths(document: Document, classifier: DocumentClassifier, user
|
||||
user = document.owner
|
||||
|
||||
if user is not None:
|
||||
storage_paths = get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_storagepath",
|
||||
StoragePath,
|
||||
storage_paths = StoragePath.objects.filter(
|
||||
id__in=permitted_object_ids(user, StoragePath, "view_storagepath"),
|
||||
)
|
||||
else:
|
||||
storage_paths = StoragePath.objects.all()
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Case
|
||||
from django.db.models import Count
|
||||
from django.db.models import IntegerField
|
||||
from django.db.models import Model
|
||||
from django.db.models import Q
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Value
|
||||
@@ -163,30 +164,32 @@ def set_permissions_for_object(
|
||||
)
|
||||
|
||||
|
||||
def permitted_document_ids(
|
||||
user,
|
||||
def permitted_object_ids(
|
||||
user: User | None,
|
||||
model: type[Model],
|
||||
perm: str,
|
||||
*,
|
||||
perm: str = "view_document",
|
||||
include_deleted: bool = False,
|
||||
):
|
||||
) -> QuerySet[int]:
|
||||
"""
|
||||
Return a queryset of document IDs the user has ``perm`` on (default
|
||||
``"view_document"``). By default limited to non-deleted documents; pass
|
||||
``include_deleted=True`` for callers that need to check permission on
|
||||
soft-deleted documents (e.g. trash restore). This intentionally avoids
|
||||
``get_objects_for_user`` to keep the subquery small and index-friendly.
|
||||
Generic version of ``permitted_document_ids`` for any model with an
|
||||
``owner`` field and guardian object-level permissions. ``include_deleted``
|
||||
only has an effect for models exposing a ``global_objects``/``deleted_at``
|
||||
soft-delete pattern (currently only ``Document``); for every other model
|
||||
it is accepted but has no effect, since those models have no soft-delete
|
||||
concept.
|
||||
"""
|
||||
|
||||
manager = Document.global_objects if include_deleted else Document.objects
|
||||
base_docs = manager.all()
|
||||
base_docs = base_docs.only("id", "owner")
|
||||
has_soft_delete = hasattr(model, "global_objects")
|
||||
manager = (
|
||||
model.global_objects if include_deleted and has_soft_delete else model.objects
|
||||
)
|
||||
base_qs = manager.all().only("id", "owner")
|
||||
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
# Just Anonymous user e.g. for drf-spectacular
|
||||
return base_docs.filter(owner__isnull=True).values_list("id", flat=True)
|
||||
return base_qs.filter(owner__isnull=True).values_list("id", flat=True)
|
||||
|
||||
if getattr(user, "is_superuser", False):
|
||||
return base_docs.values_list("id", flat=True)
|
||||
return base_qs.values_list("id", flat=True)
|
||||
|
||||
# Guardian's UserObjectPermission/GroupObjectPermission always store a bare
|
||||
# codename, but has_perm()-style callers commonly pass the qualified
|
||||
@@ -194,31 +197,46 @@ def permitted_document_ids(
|
||||
# codename, so just drop any prefix rather than silently under-permitting.
|
||||
perm = perm.rsplit(".", 1)[-1]
|
||||
|
||||
document_ct = ContentType.objects.get_for_model(Document)
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
perm_filter = {
|
||||
"permission__codename": perm,
|
||||
"permission__content_type": document_ct,
|
||||
"permission__content_type": content_type,
|
||||
}
|
||||
|
||||
user_perm_docs = (
|
||||
user_perm_ids = (
|
||||
UserObjectPermission.objects.filter(user=user, **perm_filter)
|
||||
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
||||
.values_list("object_pk_int", flat=True)
|
||||
)
|
||||
|
||||
group_perm_docs = (
|
||||
group_perm_ids = (
|
||||
GroupObjectPermission.objects.filter(group__user=user, **perm_filter)
|
||||
.annotate(object_pk_int=Cast("object_pk", IntegerField()))
|
||||
.values_list("object_pk_int", flat=True)
|
||||
)
|
||||
permitted_ids = user_perm_ids.union(group_perm_ids)
|
||||
|
||||
permitted_documents = user_perm_docs.union(group_perm_docs)
|
||||
|
||||
return base_docs.filter(
|
||||
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_documents),
|
||||
return base_qs.filter(
|
||||
Q(owner=user) | Q(owner__isnull=True) | Q(id__in=permitted_ids),
|
||||
).values_list("id", flat=True)
|
||||
|
||||
|
||||
def permitted_document_ids(
|
||||
user: User | None,
|
||||
*,
|
||||
perm: str = "view_document",
|
||||
include_deleted: bool = False,
|
||||
) -> QuerySet[int]:
|
||||
"""
|
||||
Document-specific convenience wrapper around ``permitted_object_ids``.
|
||||
Return a queryset of document IDs the user has ``perm`` on (default
|
||||
``"view_document"``). By default limited to non-deleted documents; pass
|
||||
``include_deleted=True`` for callers that need to check permission on
|
||||
soft-deleted documents (e.g. trash restore). This intentionally avoids
|
||||
``get_objects_for_user`` to keep the subquery small and index-friendly.
|
||||
"""
|
||||
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
|
||||
|
||||
|
||||
def get_document_count_filter_for_user(user, related_name: str = "documents"):
|
||||
"""
|
||||
Return the Q object used to filter document counts for the given user.
|
||||
@@ -341,6 +359,13 @@ def get_objects_for_user_owner_aware(
|
||||
"""
|
||||
Returns objects the user owns, are unowned, or has explicit perms.
|
||||
When include_deleted is True, soft-deleted items are also included.
|
||||
|
||||
Legacy slow path (guardian-backed, O(n) style permission resolution).
|
||||
Most queryset-filtering call sites have migrated onto
|
||||
``PermittedObjectsFilter``/``permitted_object_ids()``, but this function
|
||||
is kept because production callers still remain. Several callers remain
|
||||
across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` --
|
||||
grep for this function name before removing it.
|
||||
"""
|
||||
manager = (
|
||||
Model.global_objects
|
||||
@@ -360,6 +385,15 @@ def get_objects_for_user_owner_aware(
|
||||
|
||||
|
||||
def has_perms_owner_aware(user, perms, obj):
|
||||
"""
|
||||
Legacy slow path (guardian-backed) single-object permission check.
|
||||
|
||||
The queryset-filtering side of this migrated onto
|
||||
``PermittedObjectsFilter``/``permitted_object_ids()``, but this
|
||||
single-object check still has many production callers. Several callers
|
||||
remain across ``documents/``, ``paperless_mail/``, and ``paperless_ai/``
|
||||
-- grep for this function name before removing it.
|
||||
"""
|
||||
checker = ObjectPermissionChecker(user)
|
||||
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
||||
|
||||
|
||||
@@ -1675,52 +1675,6 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
||||
root_document_id = serializers.IntegerField(required=True)
|
||||
version_label = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
def validate_version_label(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
def validate(self, attrs):
|
||||
documents = attrs["documents"]
|
||||
if len(documents) < 2:
|
||||
raise serializers.ValidationError(
|
||||
"At least two documents are required.",
|
||||
)
|
||||
if "version_label" in attrs and len(documents) != 2:
|
||||
raise serializers.ValidationError(
|
||||
"version_label can only be used when merging one source document.",
|
||||
)
|
||||
if attrs["root_document_id"] not in documents:
|
||||
raise serializers.ValidationError(
|
||||
"root_document_id must be one of the selected documents.",
|
||||
)
|
||||
|
||||
selected_documents = Document.objects.filter(id__in=documents)
|
||||
if selected_documents.filter(root_document__isnull=False).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Only top-level documents can be merged as versions.",
|
||||
)
|
||||
|
||||
source_document_ids = set(documents) - {attrs["root_document_id"]}
|
||||
if Document.objects.filter(
|
||||
root_document_id__in=source_document_ids,
|
||||
).exists():
|
||||
raise serializers.ValidationError(
|
||||
"Documents with existing versions cannot be merged into another document.",
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
||||
operations = serializers.ListField(required=True)
|
||||
delete_original = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
@@ -48,7 +48,6 @@ class TestApiSchema(APITestCase):
|
||||
self.assertIn("/api/documents/reprocess/", paths)
|
||||
self.assertIn("/api/documents/rotate/", paths)
|
||||
self.assertIn("/api/documents/merge/", paths)
|
||||
self.assertIn("/api/documents/merge_as_versions/", paths)
|
||||
self.assertIn("/api/documents/edit_pdf/", paths)
|
||||
self.assertIn("/api/documents/remove_password/", paths)
|
||||
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.bulk_edit import merge_as_versions
|
||||
from documents.models import Document
|
||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||
|
||||
|
||||
class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.doc1 = Document.objects.create(checksum="A", title="A")
|
||||
self.doc2 = Document.objects.create(checksum="B", title="B")
|
||||
self.doc3 = Document.objects.create(checksum="C", title="C")
|
||||
|
||||
def test_accepts_selected_root_document(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertEqual(
|
||||
serializer.validated_data,
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
},
|
||||
)
|
||||
|
||||
def test_requires_at_least_two_documents(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors["non_field_errors"][0],
|
||||
"At least two documents are required.",
|
||||
)
|
||||
|
||||
def test_accepts_version_label_for_one_source_document(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
"version_label": " Imported ",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertEqual(serializer.validated_data["version_label"], "Imported")
|
||||
|
||||
def test_rejects_version_label_for_multiple_source_documents(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
"version_label": "Imported",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors["non_field_errors"][0],
|
||||
"version_label can only be used when merging one source document.",
|
||||
)
|
||||
|
||||
def test_requires_root_document_to_be_selected(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc3.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors["non_field_errors"][0],
|
||||
"root_document_id must be one of the selected documents.",
|
||||
)
|
||||
|
||||
def test_rejects_duplicate_documents(self) -> None:
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc1.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("documents", serializer.errors)
|
||||
|
||||
def test_rejects_selected_version(self) -> None:
|
||||
version = Document.objects.create(
|
||||
checksum="D",
|
||||
title="D",
|
||||
root_document=self.doc1,
|
||||
version_index=1,
|
||||
)
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [version.id, self.doc2.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors["non_field_errors"][0],
|
||||
"Only top-level documents can be merged as versions.",
|
||||
)
|
||||
|
||||
def test_rejects_source_document_with_versions(self) -> None:
|
||||
Document.objects.create(
|
||||
checksum="D",
|
||||
title="D",
|
||||
root_document=self.doc1,
|
||||
version_index=1,
|
||||
)
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertEqual(
|
||||
serializer.errors["non_field_errors"][0],
|
||||
"Documents with existing versions cannot be merged into another document.",
|
||||
)
|
||||
|
||||
def test_allows_root_document_with_versions(self) -> None:
|
||||
Document.objects.create(
|
||||
checksum="D",
|
||||
title="D",
|
||||
root_document=self.doc1,
|
||||
version_index=1,
|
||||
)
|
||||
serializer = MergeDocumentsAsVersionsSerializer(
|
||||
data={
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
|
||||
|
||||
class TestMergeDocumentsAsVersions(TestCase):
|
||||
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_edit.remove_document_from_index.apply_async")
|
||||
def test_merges_documents_in_creation_order(
|
||||
self,
|
||||
remove_from_index_mock,
|
||||
bulk_update_mock,
|
||||
status_manager_mock,
|
||||
) -> None:
|
||||
root = Document.objects.create(checksum="A", title="Root")
|
||||
existing_version = Document.objects.create(
|
||||
checksum="B",
|
||||
title="Existing version",
|
||||
root_document=root,
|
||||
version_index=3,
|
||||
)
|
||||
source1 = Document.objects.create(
|
||||
checksum="C",
|
||||
title="Source 1",
|
||||
archive_serial_number=1,
|
||||
)
|
||||
source2 = Document.objects.create(
|
||||
checksum="D",
|
||||
title="Source 2",
|
||||
archive_serial_number=2,
|
||||
)
|
||||
original_modified = root.modified
|
||||
|
||||
result = merge_as_versions(
|
||||
[source2.id, root.id, source1.id],
|
||||
root_document_id=root.id,
|
||||
)
|
||||
|
||||
self.assertEqual(result, "OK")
|
||||
source1.refresh_from_db()
|
||||
source2.refresh_from_db()
|
||||
root.refresh_from_db()
|
||||
self.assertEqual(source2.root_document_id, root.id)
|
||||
self.assertEqual(source2.version_index, 5)
|
||||
self.assertEqual(source1.root_document_id, root.id)
|
||||
self.assertEqual(source1.version_index, 4)
|
||||
self.assertIsNone(source1.archive_serial_number)
|
||||
self.assertIsNone(source2.archive_serial_number)
|
||||
self.assertGreater(root.modified, original_modified)
|
||||
self.assertEqual(existing_version.root_document_id, root.id)
|
||||
|
||||
self.assertEqual(
|
||||
[call.kwargs["args"] for call in remove_from_index_mock.call_args_list],
|
||||
[[source1.id], [source2.id]],
|
||||
)
|
||||
bulk_update_mock.assert_called_once_with(
|
||||
kwargs={"document_ids": [root.id]},
|
||||
headers={"trigger_source": "system"},
|
||||
)
|
||||
status_manager_mock.return_value.send_documents_deleted.assert_called_once_with(
|
||||
[source1.id, source2.id],
|
||||
)
|
||||
|
||||
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_edit.remove_document_from_index.apply_async")
|
||||
def test_sets_version_label_for_one_source_document(
|
||||
self,
|
||||
_remove_from_index_mock,
|
||||
_bulk_update_mock,
|
||||
_status_manager_mock,
|
||||
) -> None:
|
||||
root = Document.objects.create(checksum="A", title="Root")
|
||||
source = Document.objects.create(checksum="B", title="Source")
|
||||
|
||||
merge_as_versions(
|
||||
[root.id, source.id],
|
||||
root_document_id=root.id,
|
||||
version_label="Imported",
|
||||
)
|
||||
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.version_label, "Imported")
|
||||
|
||||
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_edit.remove_document_from_index.apply_async")
|
||||
def test_rejects_source_document_with_versions(
|
||||
self,
|
||||
remove_from_index_mock,
|
||||
bulk_update_mock,
|
||||
status_manager_mock,
|
||||
) -> None:
|
||||
source = Document.objects.create(checksum="A", title="Source")
|
||||
Document.objects.create(
|
||||
checksum="B",
|
||||
title="Source version",
|
||||
root_document=source,
|
||||
version_index=1,
|
||||
)
|
||||
root = Document.objects.create(checksum="C", title="Root")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "existing versions"):
|
||||
merge_as_versions(
|
||||
[source.id, root.id],
|
||||
root_document_id=root.id,
|
||||
)
|
||||
|
||||
source.refresh_from_db()
|
||||
self.assertIsNone(source.root_document_id)
|
||||
remove_from_index_mock.assert_not_called()
|
||||
bulk_update_mock.assert_not_called()
|
||||
status_manager_mock.assert_not_called()
|
||||
|
||||
|
||||
class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user(username="user")
|
||||
self.user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
self.doc1 = Document.objects.create(
|
||||
checksum="A",
|
||||
title="A",
|
||||
owner=self.user,
|
||||
)
|
||||
self.doc2 = Document.objects.create(
|
||||
checksum="B",
|
||||
title="B",
|
||||
owner=self.user,
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||
def test_merges_documents_as_versions(self, merge_mock) -> None:
|
||||
merge_mock.return_value = "OK"
|
||||
merge_mock.__name__ = "merge_as_versions"
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
"version_label": "Imported",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, {"result": "OK"})
|
||||
merge_mock.assert_called_once_with(
|
||||
[self.doc1.id, self.doc2.id],
|
||||
root_document_id=self.doc2.id,
|
||||
version_label="Imported",
|
||||
)
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||
def test_requires_change_permission(self, merge_mock) -> None:
|
||||
merge_mock.__name__ = "merge_as_versions"
|
||||
user = User.objects.create_user(username="no-change")
|
||||
self.doc1.owner = user
|
||||
self.doc1.save()
|
||||
self.doc2.owner = user
|
||||
self.doc2.save()
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc1.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
merge_mock.assert_not_called()
|
||||
|
||||
@mock.patch("documents.views.bulk_edit.merge_as_versions")
|
||||
def test_rejects_unselected_root(self, merge_mock) -> None:
|
||||
doc3 = Document.objects.create(
|
||||
checksum="C",
|
||||
title="C",
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": doc3.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
merge_mock.assert_not_called()
|
||||
|
||||
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_edit.remove_document_from_index.apply_async")
|
||||
def test_merges_and_returns_documents_as_versions(
|
||||
self,
|
||||
remove_from_index_mock,
|
||||
bulk_update_mock,
|
||||
status_manager_mock,
|
||||
) -> None:
|
||||
response = self.client.post(
|
||||
"/api/documents/merge_as_versions/",
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
"version_label": "Imported",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.doc1.refresh_from_db()
|
||||
self.assertEqual(self.doc1.root_document_id, self.doc2.id)
|
||||
self.assertEqual(self.doc1.version_label, "Imported")
|
||||
|
||||
detail_response = self.client.get(
|
||||
f"/api/documents/{self.doc2.id}/?fields=id,versions",
|
||||
)
|
||||
self.assertEqual(detail_response.status_code, status.HTTP_200_OK)
|
||||
versions = detail_response.data["versions"]
|
||||
self.assertEqual(
|
||||
{version["id"] for version in versions},
|
||||
{self.doc1.id, self.doc2.id},
|
||||
)
|
||||
self.assertEqual(
|
||||
[version["id"] for version in versions if version["is_root"]],
|
||||
[self.doc2.id],
|
||||
)
|
||||
remove_from_index_mock.assert_called_once_with(args=[self.doc1.id])
|
||||
bulk_update_mock.assert_called_once_with(
|
||||
kwargs={"document_ids": [self.doc2.id]},
|
||||
headers={"trigger_source": "system"},
|
||||
)
|
||||
status_manager_mock.return_value.send_documents_deleted.assert_called_once_with(
|
||||
[self.doc1.id],
|
||||
)
|
||||
@@ -12,9 +12,22 @@ from django.test import override_settings
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.matching import match_correspondents
|
||||
from documents.matching import match_document_types
|
||||
from documents.matching import match_storage_paths
|
||||
from documents.matching import match_tags
|
||||
from documents.models import Correspondent
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.serialisers import _get_viewable_duplicates
|
||||
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
|
||||
|
||||
|
||||
def assert_visible_document_ids(actual_ids, *, expected_visible, expected_hidden):
|
||||
@@ -431,3 +444,320 @@ class TestTrashRestorePermissionBoundary:
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||
"""
|
||||
Regression test pinning TrashView's use of
|
||||
``_TrashPermittedObjectsFilter`` (``include_granted = False``). If that
|
||||
flag were ever flipped to the default ``True``, or the subclass removed
|
||||
in favor of the base ``PermittedObjectsFilter``, a trashed document
|
||||
would leak into ``/api/trash/`` results for any user holding an
|
||||
explicit guardian grant on it, even though they are neither the owner
|
||||
nor a superuser.
|
||||
"""
|
||||
|
||||
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
||||
owner = User.objects.create_user(username="trash_owner")
|
||||
grantee = User.objects.create_user(username="trash_grantee")
|
||||
doc = DocumentFactory(owner=owner)
|
||||
doc.delete() # soft delete
|
||||
assign_perm("view_document", grantee, doc)
|
||||
|
||||
rest_api_client.force_authenticate(user=grantee)
|
||||
response = rest_api_client.get("/api/trash/")
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
result_ids = {result["id"] for result in response.data["results"]}
|
||||
assert doc.pk not in result_ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("model", "factory", "perm"),
|
||||
[
|
||||
(Tag, TagFactory, "view_tag"),
|
||||
(Correspondent, CorrespondentFactory, "view_correspondent"),
|
||||
(DocumentType, DocumentTypeFactory, "view_documenttype"),
|
||||
(StoragePath, StoragePathFactory, "view_storagepath"),
|
||||
],
|
||||
)
|
||||
class TestPermittedObjectIdsGenericModels:
|
||||
def test_owner_sees_own_object(self, model, factory, perm):
|
||||
owner = User.objects.create_user(username=f"owner_{model.__name__}")
|
||||
stranger = User.objects.create_user(username=f"stranger_{model.__name__}")
|
||||
owned = factory(owner=owner)
|
||||
strangers = factory(owner=stranger)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(owner, model, perm),
|
||||
expected_visible=[owned.pk],
|
||||
expected_hidden=[strangers.pk],
|
||||
)
|
||||
|
||||
def test_unowned_object_visible_to_everyone(self, model, factory, perm):
|
||||
user = User.objects.create_user(username=f"user_{model.__name__}")
|
||||
unowned = factory(owner=None)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(user, model, perm),
|
||||
expected_visible=[unowned.pk],
|
||||
expected_hidden=[],
|
||||
)
|
||||
|
||||
def test_explicit_permission_grants_visibility(self, model, factory, perm):
|
||||
owner = User.objects.create_user(username=f"owner2_{model.__name__}")
|
||||
grantee = User.objects.create_user(username=f"grantee_{model.__name__}")
|
||||
stranger = User.objects.create_user(username=f"stranger2_{model.__name__}")
|
||||
shared = factory(owner=owner)
|
||||
not_shared = factory(owner=owner)
|
||||
assign_perm(perm, grantee, shared)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(grantee, model, perm),
|
||||
expected_visible=[shared.pk],
|
||||
expected_hidden=[not_shared.pk],
|
||||
)
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(stranger, model, perm),
|
||||
expected_visible=[],
|
||||
expected_hidden=[shared.pk, not_shared.pk],
|
||||
)
|
||||
|
||||
def test_group_permission_grants_visibility_to_members_only(
|
||||
self,
|
||||
model,
|
||||
factory,
|
||||
perm,
|
||||
):
|
||||
owner = User.objects.create_user(username=f"owner3_{model.__name__}")
|
||||
member = User.objects.create_user(username=f"member_{model.__name__}")
|
||||
non_member = User.objects.create_user(username=f"nonmember_{model.__name__}")
|
||||
group = Group.objects.create(name=f"group_{model.__name__}")
|
||||
member.groups.add(group)
|
||||
shared = factory(owner=owner)
|
||||
assign_perm(perm, group, shared)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(member, model, perm),
|
||||
expected_visible=[shared.pk],
|
||||
expected_hidden=[],
|
||||
)
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(non_member, model, perm),
|
||||
expected_visible=[],
|
||||
expected_hidden=[shared.pk],
|
||||
)
|
||||
|
||||
def test_superuser_sees_everything(self, model, factory, perm):
|
||||
superuser = User.objects.create_superuser(username=f"root_{model.__name__}")
|
||||
owner = User.objects.create_user(username=f"owner4_{model.__name__}")
|
||||
obj = factory(owner=owner)
|
||||
|
||||
assert_visible_document_ids(
|
||||
permitted_object_ids(superuser, model, perm),
|
||||
expected_visible=[obj.pk],
|
||||
expected_hidden=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMatchingRespectsObjectPermissions:
|
||||
def test_match_tags_only_considers_tags_visible_to_user(self):
|
||||
owner = User.objects.create_user(username="tag_owner")
|
||||
classifying_user = User.objects.create_user(username="classifier_user")
|
||||
visible_tag = TagFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
matching_algorithm=Tag.MATCH_LITERAL,
|
||||
)
|
||||
hidden_tag = TagFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
matching_algorithm=Tag.MATCH_LITERAL,
|
||||
)
|
||||
assign_perm("view_tag", classifying_user, visible_tag)
|
||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||
|
||||
matched = match_tags(doc, classifier=None, user=classifying_user)
|
||||
matched_ids = {t.pk for t in matched}
|
||||
assert visible_tag.pk in matched_ids
|
||||
assert hidden_tag.pk not in matched_ids
|
||||
|
||||
def test_match_correspondents_only_considers_correspondents_visible_to_user(self):
|
||||
owner = User.objects.create_user(username="correspondent_owner")
|
||||
classifying_user = User.objects.create_user(username="classifier_user2")
|
||||
visible_correspondent = CorrespondentFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
matching_algorithm=Correspondent.MATCH_LITERAL,
|
||||
)
|
||||
hidden_correspondent = CorrespondentFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
matching_algorithm=Correspondent.MATCH_LITERAL,
|
||||
)
|
||||
assign_perm("view_correspondent", classifying_user, visible_correspondent)
|
||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||
|
||||
matched = match_correspondents(doc, classifier=None, user=classifying_user)
|
||||
matched_ids = {c.pk for c in matched}
|
||||
assert visible_correspondent.pk in matched_ids
|
||||
assert hidden_correspondent.pk not in matched_ids
|
||||
|
||||
def test_match_document_types_only_considers_document_types_visible_to_user(self):
|
||||
owner = User.objects.create_user(username="document_type_owner")
|
||||
classifying_user = User.objects.create_user(username="classifier_user3")
|
||||
visible_document_type = DocumentTypeFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
matching_algorithm=DocumentType.MATCH_LITERAL,
|
||||
)
|
||||
hidden_document_type = DocumentTypeFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
matching_algorithm=DocumentType.MATCH_LITERAL,
|
||||
)
|
||||
assign_perm("view_documenttype", classifying_user, visible_document_type)
|
||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||
|
||||
matched = match_document_types(doc, classifier=None, user=classifying_user)
|
||||
matched_ids = {dt.pk for dt in matched}
|
||||
assert visible_document_type.pk in matched_ids
|
||||
assert hidden_document_type.pk not in matched_ids
|
||||
|
||||
def test_match_storage_paths_only_considers_storage_paths_visible_to_user(self):
|
||||
owner = User.objects.create_user(username="storage_path_owner")
|
||||
classifying_user = User.objects.create_user(username="classifier_user4")
|
||||
visible_storage_path = StoragePathFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
matching_algorithm=StoragePath.MATCH_LITERAL,
|
||||
)
|
||||
hidden_storage_path = StoragePathFactory(
|
||||
owner=owner,
|
||||
match="invoice",
|
||||
matching_algorithm=StoragePath.MATCH_LITERAL,
|
||||
)
|
||||
assign_perm("view_storagepath", classifying_user, visible_storage_path)
|
||||
doc = DocumentFactory(owner=classifying_user, content="an invoice document")
|
||||
|
||||
matched = match_storage_paths(doc, classifier=None, user=classifying_user)
|
||||
matched_ids = {sp.pk for sp in matched}
|
||||
assert visible_storage_path.pk in matched_ids
|
||||
assert hidden_storage_path.pk not in matched_ids
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
||||
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
|
||||
owner = User.objects.create_user(username="tags_owner")
|
||||
requester = User.objects.create_user(username="tags_requester")
|
||||
# grant the global change_tag permission so the object-level
|
||||
# filtering (not the global has_perm check) is what's under test
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
visible = TagFactory(owner=owner)
|
||||
hidden = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", requester, visible)
|
||||
assign_perm("change_tag", requester, visible)
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
{
|
||||
"object_type": "tags",
|
||||
"operation": "set_permissions",
|
||||
"all": True,
|
||||
"filters": {},
|
||||
"owner": requester.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
# The apply_to_all dispatch must resolve permitted objects up front:
|
||||
# the visible tag (object-level change_tag granted) gets its owner
|
||||
# reassigned, while the hidden tag (no object-level grant) is
|
||||
# excluded entirely and keeps its original owner.
|
||||
visible.refresh_from_db()
|
||||
hidden.refresh_from_db()
|
||||
assert visible.owner == requester
|
||||
assert hidden.owner == owner
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
def test_apply_to_all_descendant_expansion_respects_per_object_permissions(
|
||||
self,
|
||||
rest_api_client,
|
||||
):
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag hierarchy (parent -> permitted_child, unpermitted_child)
|
||||
- A non-superuser requester with object-level change_tag granted
|
||||
on the parent and on only ONE of the two children
|
||||
WHEN:
|
||||
- bulk_edit_objects is called with all=True and a filter that
|
||||
matches only the root (parent) tag, engaging the
|
||||
tag-descendant-expansion logic in BulkEditObjectsView.post
|
||||
THEN:
|
||||
- The descendant expansion only pulls in descendants the
|
||||
requester actually has permission on: the permitted child's
|
||||
owner is reassigned alongside the parent's, while the
|
||||
unpermitted child keeps its original owner. This pins that the
|
||||
expansion checks per-object permissions (editable_ids), not
|
||||
merely "is a descendant of a filter match".
|
||||
|
||||
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
|
||||
``delete`` as the operation, because Tag.tn_parent (django-treenode)
|
||||
cascades deletes to descendants at the database/ORM level regardless
|
||||
of which tags the view resolved into ``objs`` -- a delete-based test
|
||||
would pass/fail based on FK cascade behavior, not on whether the
|
||||
descendant-expansion logic itself respected per-object permissions.
|
||||
"""
|
||||
owner = User.objects.create_user(username="tag_hierarchy_owner")
|
||||
requester = User.objects.create_user(username="tag_hierarchy_requester")
|
||||
# global change_tag permission so the has_perm() gate passes and the
|
||||
# object-level permitted_object_ids filtering is what's under test
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
|
||||
parent = TagFactory(owner=owner, name="parent-tag")
|
||||
permitted_child = TagFactory(
|
||||
owner=owner,
|
||||
name="permitted-child-tag",
|
||||
tn_parent=parent,
|
||||
)
|
||||
unpermitted_child = TagFactory(
|
||||
owner=owner,
|
||||
name="unpermitted-child-tag",
|
||||
tn_parent=parent,
|
||||
)
|
||||
assign_perm("change_tag", requester, parent)
|
||||
assign_perm("change_tag", requester, permitted_child)
|
||||
# unpermitted_child is intentionally NOT granted change_tag
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
{
|
||||
"object_type": "tags",
|
||||
"operation": "set_permissions",
|
||||
"all": True,
|
||||
"filters": {"is_root": True},
|
||||
"owner": requester.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
parent.refresh_from_db()
|
||||
permitted_child.refresh_from_db()
|
||||
unpermitted_child.refresh_from_db()
|
||||
assert parent.owner == requester
|
||||
assert permitted_child.owner == requester
|
||||
assert unpermitted_child.owner == owner
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import User
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
from documents.filters import PermittedObjectsFilter
|
||||
from documents.models import Tag
|
||||
from documents.tests.factories import TagFactory
|
||||
|
||||
|
||||
class _DummyView:
|
||||
queryset = Tag.objects.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPermittedObjectsFilter:
|
||||
def test_superuser_bypasses_filtering_entirely(self):
|
||||
superuser = User.objects.create_superuser(username="root")
|
||||
owner = User.objects.create_user(username="owner")
|
||||
TagFactory(owner=owner)
|
||||
request = APIRequestFactory().get("/")
|
||||
request.user = superuser
|
||||
|
||||
result = PermittedObjectsFilter().filter_queryset(
|
||||
request,
|
||||
Tag.objects.all(),
|
||||
_DummyView(),
|
||||
)
|
||||
assert result.count() == Tag.objects.count()
|
||||
|
||||
def test_non_superuser_sees_only_owned_unowned_and_granted(self):
|
||||
owner = User.objects.create_user(username="owner")
|
||||
grantee = User.objects.create_user(username="grantee")
|
||||
owned = TagFactory(owner=grantee)
|
||||
unowned = TagFactory(owner=None)
|
||||
granted = TagFactory(owner=owner)
|
||||
hidden = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", grantee, granted)
|
||||
request = APIRequestFactory().get("/")
|
||||
request.user = grantee
|
||||
|
||||
result = PermittedObjectsFilter().filter_queryset(
|
||||
request,
|
||||
Tag.objects.all(),
|
||||
_DummyView(),
|
||||
)
|
||||
visible_ids = set(result.values_list("id", flat=True))
|
||||
assert visible_ids == {owned.pk, unowned.pk, granted.pk}
|
||||
assert hidden.pk not in visible_ids
|
||||
|
||||
def test_include_granted_false_excludes_explicitly_shared_objects(self):
|
||||
owner = User.objects.create_user(username="owner2")
|
||||
grantee = User.objects.create_user(username="grantee2")
|
||||
owned = TagFactory(owner=grantee)
|
||||
granted = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", grantee, granted)
|
||||
request = APIRequestFactory().get("/")
|
||||
request.user = grantee
|
||||
|
||||
class _OwnerOnlyFilter(PermittedObjectsFilter):
|
||||
include_granted = False
|
||||
|
||||
result = _OwnerOnlyFilter().filter_queryset(
|
||||
request,
|
||||
Tag.objects.all(),
|
||||
_DummyView(),
|
||||
)
|
||||
visible_ids = set(result.values_list("id", flat=True))
|
||||
assert visible_ids == {owned.pk}
|
||||
assert granted.pk not in visible_ids
|
||||
+22
-47
@@ -133,12 +133,10 @@ from documents.file_handling import format_filename
|
||||
from documents.filters import CorrespondentFilterSet
|
||||
from documents.filters import CustomFieldFilterSet
|
||||
from documents.filters import DocumentFilterSet
|
||||
from documents.filters import DocumentPermissionsFilter
|
||||
from documents.filters import DocumentsOrderingFilter
|
||||
from documents.filters import DocumentTypeFilterSet
|
||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
||||
from documents.filters import ObjectOwnedPermissionsFilter
|
||||
from documents.filters import PaperlessTaskFilterSet
|
||||
from documents.filters import PermittedObjectsFilter
|
||||
from documents.filters import ShareLinkBundleFilterSet
|
||||
from documents.filters import ShareLinkFilterSet
|
||||
from documents.filters import StoragePathFilterSet
|
||||
@@ -178,6 +176,7 @@ from documents.permissions import has_global_statistics_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
from documents.schema import generate_object_with_permissions_schema
|
||||
@@ -196,7 +195,6 @@ from documents.serialisers import DocumentVersionLabelSerializer
|
||||
from documents.serialisers import DocumentVersionSerializer
|
||||
from documents.serialisers import EditPdfDocumentsSerializer
|
||||
from documents.serialisers import EmailSerializer
|
||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||
from documents.serialisers import MergeDocumentsSerializer
|
||||
from documents.serialisers import NotesSerializer
|
||||
from documents.serialisers import PostDocumentSerializer
|
||||
@@ -551,7 +549,7 @@ class CorrespondentViewSet(
|
||||
filter_backends = (
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = CorrespondentFilterSet
|
||||
ordering_fields = (
|
||||
@@ -592,7 +590,7 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
|
||||
filter_backends = (
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = TagFilterSet
|
||||
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
|
||||
@@ -684,7 +682,7 @@ class DocumentTypeViewSet(
|
||||
filter_backends = (
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = DocumentTypeFilterSet
|
||||
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
|
||||
@@ -988,7 +986,7 @@ class DocumentViewSet(
|
||||
DjangoFilterBackend,
|
||||
SearchFilter,
|
||||
DocumentsOrderingFilter,
|
||||
DocumentPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = DocumentFilterSet
|
||||
search_fields = ("title", "correspondent__name", "effective_content")
|
||||
@@ -2674,7 +2672,7 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet[SavedVie
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
filter_backends = (
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
ordering_fields = ("name",)
|
||||
|
||||
@@ -2809,7 +2807,6 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
bulk_edit.rotate,
|
||||
bulk_edit.delete_pages,
|
||||
bulk_edit.edit_pdf,
|
||||
bulk_edit.merge_as_versions,
|
||||
bulk_edit.remove_password,
|
||||
]
|
||||
)
|
||||
@@ -3096,33 +3093,6 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
operation_id="documents_merge_as_versions",
|
||||
description="Merge selected documents as versions of a chosen root document",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="MergeDocumentsAsVersionsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
|
||||
serializer_class = MergeDocumentsAsVersionsSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return self._execute_document_action(
|
||||
method=bulk_edit.merge_as_versions,
|
||||
validated_data=serializer.validated_data,
|
||||
operation_label="document merge as versions",
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
operation_id="documents_delete",
|
||||
@@ -3949,7 +3919,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Storag
|
||||
filter_backends = (
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = StoragePathFilterSet
|
||||
ordering_fields = ("name", "path", "matching_algorithm", "match", "document_count")
|
||||
@@ -4480,7 +4450,7 @@ class ShareLinkViewSet(
|
||||
filter_backends = (
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = ShareLinkFilterSet
|
||||
ordering_fields = ("created", "expiration", "document")
|
||||
@@ -4510,7 +4480,7 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
filter_backends = (
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = ShareLinkBundleFilterSet
|
||||
ordering_fields = ("created", "expiration", "status")
|
||||
@@ -4793,10 +4763,8 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
"document_types": DocumentTypeFilterSet,
|
||||
"storage_paths": StoragePathFilterSet,
|
||||
}[object_type]
|
||||
user_permitted_objects = get_objects_for_user_owner_aware(
|
||||
user,
|
||||
perm_codename,
|
||||
object_class,
|
||||
user_permitted_objects = object_class.objects.filter(
|
||||
id__in=permitted_object_ids(user, object_class, perm_codename),
|
||||
)
|
||||
objs = filterset_class(
|
||||
data=filters,
|
||||
@@ -4821,8 +4789,11 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
|
||||
if not user.is_superuser:
|
||||
perm = f"documents.{perm_codename}"
|
||||
has_perms = user.has_perm(perm) and all(
|
||||
has_perms_owner_aware(user, perm_codename, obj) for obj in objs
|
||||
has_perms = (
|
||||
user.has_perm(perm)
|
||||
and not objs.exclude(
|
||||
pk__in=permitted_object_ids(user, object_class, perm_codename),
|
||||
).exists()
|
||||
)
|
||||
|
||||
if not has_perms:
|
||||
@@ -5323,7 +5294,11 @@ class SystemStatusView(PassUserMixin):
|
||||
class TrashView(ListModelMixin, PassUserMixin):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = TrashSerializer
|
||||
filter_backends = (ObjectOwnedPermissionsFilter,)
|
||||
|
||||
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
||||
include_granted = False
|
||||
|
||||
filter_backends = (_TrashPermittedObjectsFilter,)
|
||||
pagination_class = StandardPagination
|
||||
|
||||
model = Document
|
||||
|
||||
@@ -27,7 +27,6 @@ from documents.views import EditPdfDocumentsView
|
||||
from documents.views import GlobalSearchView
|
||||
from documents.views import IndexView
|
||||
from documents.views import LogViewSet
|
||||
from documents.views import MergeDocumentsAsVersionsView
|
||||
from documents.views import MergeDocumentsView
|
||||
from documents.views import PostDocumentView
|
||||
from documents.views import RemoteVersionView
|
||||
@@ -173,11 +172,6 @@ urlpatterns = [
|
||||
MergeDocumentsView.as_view(),
|
||||
name="merge_documents",
|
||||
),
|
||||
re_path(
|
||||
"^merge_as_versions/",
|
||||
MergeDocumentsAsVersionsView.as_view(),
|
||||
name="merge_documents_as_versions",
|
||||
),
|
||||
re_path(
|
||||
"^edit_pdf/",
|
||||
EditPdfDocumentsView.as_view(),
|
||||
|
||||
@@ -23,7 +23,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
||||
from documents.filters import PermittedObjectsFilter
|
||||
from documents.models import PaperlessTask
|
||||
from documents.permissions import PaperlessObjectPermissions
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
@@ -75,7 +75,7 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
|
||||
serializer_class = MailAccountSerializer
|
||||
pagination_class = StandardPagination
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
|
||||
filter_backends = (PermittedObjectsFilter,)
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "test":
|
||||
@@ -197,7 +197,7 @@ class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
|
||||
filter_backends = (
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = ProcessedMailFilterSet
|
||||
|
||||
@@ -225,7 +225,7 @@ class MailRuleViewSet(PassUserMixin, ModelViewSet[MailRule]):
|
||||
serializer_class = MailRuleSerializer
|
||||
pagination_class = StandardPagination
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
|
||||
filter_backends = (PermittedObjectsFilter,)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
|
||||
Reference in New Issue
Block a user