mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-08 18:57:58 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1335d152 | ||
|
|
21fb88e3f0 |
@@ -72,7 +72,7 @@ jobs:
|
||||
'You are welcome to open a new issue that describes the problem you observed in your own words.'
|
||||
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
|
||||
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
|
||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` +
|
||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n' +
|
||||
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
|
||||
`contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
|
||||
|
||||
|
||||
@@ -25,10 +25,6 @@ jobs:
|
||||
pr-bot:
|
||||
name: Automated PR Bot
|
||||
runs-on: ubuntu-latest
|
||||
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
|
||||
# instead of racing it. Still runs if that job fails, so labeling is not lost.
|
||||
needs: Anti-slop
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -103,25 +99,8 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const user = context.payload.pull_request.user.login;
|
||||
|
||||
// Re-read the PR: Anti-slop may have closed and labeled it after the webhook
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
});
|
||||
|
||||
if (pr.state === 'closed') {
|
||||
core.info('Skipping comment: PR is already closed');
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name));
|
||||
if (labels.includes('ai')) {
|
||||
core.info('Skipping comment: PR is labeled ai');
|
||||
return;
|
||||
}
|
||||
const pr = context.payload.pull_request;
|
||||
const user = pr.user.login;
|
||||
|
||||
const { data: members } = await github.rest.orgs.listMembers({
|
||||
org: 'paperless-ngx',
|
||||
|
||||
+11
-39
@@ -501,43 +501,15 @@
|
||||
<context context-type="linenumber">30</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7057826840809102816" datatype="html">
|
||||
<source>This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">35</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7221396516204435584" datatype="html">
|
||||
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">37</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8318849619178340389" datatype="html">
|
||||
<source>Use the externally configured value</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">42</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6032629623003430385" datatype="html">
|
||||
<source>Reset to external</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">43</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7808756054397155068" datatype="html">
|
||||
<source>Reset</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">46</context>
|
||||
<context context-type="linenumber">34</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">47</context>
|
||||
<context context-type="linenumber">35</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
@@ -548,7 +520,7 @@
|
||||
<source>Enable</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">56</context>
|
||||
<context context-type="linenumber">43</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
||||
@@ -559,7 +531,7 @@
|
||||
<source>Cancel</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">80,81</context>
|
||||
<context context-type="linenumber">67,68</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
@@ -638,7 +610,7 @@
|
||||
<source>Save</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
||||
<context context-type="linenumber">83,84</context>
|
||||
<context context-type="linenumber">70,71</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||
@@ -709,42 +681,42 @@
|
||||
<source>Error retrieving config</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||
<context context-type="linenumber">118</context>
|
||||
<context context-type="linenumber">117</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1172622527269118932" datatype="html">
|
||||
<source>Invalid JSON</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||
<context context-type="linenumber">144</context>
|
||||
<context context-type="linenumber">143</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5103146006962696736" datatype="html">
|
||||
<source>Configuration updated</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||
<context context-type="linenumber">193</context>
|
||||
<context context-type="linenumber">187</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1664963291286452273" datatype="html">
|
||||
<source>An error occurred updating configuration</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||
<context context-type="linenumber">198</context>
|
||||
<context context-type="linenumber">192</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2653081282186526824" datatype="html">
|
||||
<source>File successfully updated</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||
<context context-type="linenumber">220</context>
|
||||
<context context-type="linenumber">214</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5902783625859504265" datatype="html">
|
||||
<source>An error occurred uploading file</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
||||
<context context-type="linenumber">225</context>
|
||||
<context context-type="linenumber">219</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4804785061014590286" datatype="html">
|
||||
|
||||
@@ -23,30 +23,17 @@
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title d-flex align-items-center flex-wrap">
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isExternallyConfigured(option.config_key)) {
|
||||
@if (isSet(option.key)) {
|
||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
|
||||
} @else {
|
||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
|
||||
}
|
||||
}
|
||||
@if (isSet(option.key)) {
|
||||
@if (isExternallyConfigured(option.config_key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
|
||||
@@ -163,19 +163,6 @@ describe('ConfigComponent', () => {
|
||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||
})
|
||||
|
||||
it('should identify externally configured options', () => {
|
||||
component.externallyConfiguredVariables = new Set([
|
||||
'PAPERLESS_OCR_LANGUAGE',
|
||||
])
|
||||
|
||||
expect(
|
||||
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should group options into sections within a category, or not', () => {
|
||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||
|
||||
@@ -69,7 +69,6 @@ export class ConfigComponent
|
||||
public configForm = new FormGroup({})
|
||||
|
||||
public errors = {}
|
||||
public externallyConfiguredVariables = new Set<string>()
|
||||
|
||||
get optionCategories(): string[] {
|
||||
return Object.values(ConfigCategory)
|
||||
@@ -153,9 +152,6 @@ export class ConfigComponent
|
||||
}
|
||||
|
||||
private initialize(config: PaperlessConfig) {
|
||||
this.externallyConfiguredVariables = new Set(
|
||||
config.externally_configured_variables ?? []
|
||||
)
|
||||
if (!this.store) {
|
||||
this.store = new BehaviorSubject(config)
|
||||
|
||||
@@ -166,9 +162,7 @@ export class ConfigComponent
|
||||
this.configForm.patchValue(state, { emitEvent: false })
|
||||
})
|
||||
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
|
||||
excludeKeys: ['externally_configured_variables'],
|
||||
})
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||
}
|
||||
this.configForm.patchValue(config)
|
||||
|
||||
@@ -233,10 +227,6 @@ export class ConfigComponent
|
||||
return this.configForm.get(key).value != null
|
||||
}
|
||||
|
||||
public isExternallyConfigured(configKey: string): boolean {
|
||||
return this.externallyConfiguredVariables.has(configKey)
|
||||
}
|
||||
|
||||
public resetOption(key: string) {
|
||||
this.configForm.get(key).setValue(null)
|
||||
}
|
||||
|
||||
@@ -422,7 +422,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
]
|
||||
|
||||
export interface PaperlessConfig extends ObjectWithId {
|
||||
externally_configured_variables: string[]
|
||||
output_type: OutputTypeConfig
|
||||
pages: number
|
||||
language: string
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
@@ -379,7 +380,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
||||
)
|
||||
delete_ids = list({*doc_ids, *version_ids})
|
||||
|
||||
Document.objects.filter(id__in=delete_ids).delete()
|
||||
Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4())
|
||||
|
||||
from documents.search import get_backend
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Case
|
||||
from django.db.models import CharField
|
||||
from django.db.models import Count
|
||||
@@ -52,7 +53,6 @@ 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.versioning import annotate_effective_content
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -182,9 +182,14 @@ class TitleContentFilter(Filter):
|
||||
logger.warning(
|
||||
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
|
||||
)
|
||||
return annotate_effective_content(qs).filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
try:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(content__icontains=value),
|
||||
)
|
||||
else:
|
||||
return qs
|
||||
|
||||
@@ -195,9 +200,14 @@ class EffectiveContentFilter(Filter):
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if not value:
|
||||
return qs
|
||||
return annotate_effective_content(qs).filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
try:
|
||||
return qs.filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
**{f"content__{self.lookup_expr}": value},
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
|
||||
+10
-16
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
@@ -374,7 +375,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
If the queryset already annotated ``effective_content``, that value is used.
|
||||
"""
|
||||
# Here to avoid circular import
|
||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||
from documents.versioning import sort_versions_newest_first
|
||||
from documents.versioning import versions_newest_first
|
||||
|
||||
@@ -384,19 +384,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
if self.root_document_id is not None or self.pk is None:
|
||||
return self.content
|
||||
|
||||
latest_version_prefetch = getattr(
|
||||
self,
|
||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
||||
None,
|
||||
)
|
||||
if latest_version_prefetch is not None:
|
||||
# Empty list means prefetch ran and found no versions — use own content.
|
||||
return (
|
||||
latest_version_prefetch[0].content
|
||||
if latest_version_prefetch
|
||||
else self.content
|
||||
)
|
||||
|
||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
||||
prefetched_versions = (
|
||||
prefetched_cache.get("versions")
|
||||
@@ -528,13 +515,20 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
def delete(
|
||||
self,
|
||||
*args,
|
||||
transaction_id=None,
|
||||
**kwargs,
|
||||
):
|
||||
# If deleting a root document, move all its versions to trash as well.
|
||||
# Versions must share the root's transaction ID so they are restored
|
||||
# together by django-softdelete.
|
||||
if transaction_id is None:
|
||||
transaction_id = uuid.uuid4()
|
||||
if self.root_document_id is None:
|
||||
Document.objects.filter(root_document=self).delete()
|
||||
Document.objects.filter(root_document=self).delete(
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
return super().delete(
|
||||
*args,
|
||||
transaction_id=transaction_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,7 +89,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.templating.workflows import validate_workflow_template
|
||||
from documents.validators import uri_validator
|
||||
from documents.validators import url_validator
|
||||
from documents.versioning import has_prefetched_effective_content
|
||||
from documents.versioning import sort_versions_newest_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -675,9 +674,6 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
ordering = ordering or (Lower("name"),)
|
||||
children = children.order_by(*ordering)
|
||||
|
||||
if not children:
|
||||
return []
|
||||
|
||||
serializer = TagSerializer(
|
||||
children,
|
||||
many=True,
|
||||
@@ -1153,14 +1149,8 @@ class DocumentSerializer(
|
||||
|
||||
def to_representation(self, instance):
|
||||
doc = super().to_representation(instance)
|
||||
if "content" in self.fields and has_prefetched_effective_content(instance):
|
||||
# Only resolve version-aware content when it's cheap: an SQL
|
||||
# annotation or a versions prefetch is already on the instance.
|
||||
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
|
||||
# which build their own querysets) gets the document's own,
|
||||
# unresolved content instead of paying for an extra per-instance
|
||||
# query -- same as before effective_content resolution existed.
|
||||
doc["content"] = instance.get_effective_content() or ""
|
||||
if "content" in self.fields and hasattr(instance, "effective_content"):
|
||||
doc["content"] = getattr(instance, "effective_content") or ""
|
||||
if self.truncate_content and "content" in self.fields:
|
||||
doc["content"] = doc.get("content")[0:550]
|
||||
return doc
|
||||
|
||||
@@ -35,8 +35,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Existing config
|
||||
"""
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -46,7 +45,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
response.data[0],
|
||||
{
|
||||
"id": 1,
|
||||
"externally_configured_variables": [],
|
||||
"output_type": None,
|
||||
"pages": None,
|
||||
"language": None,
|
||||
@@ -93,31 +91,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_api_get_config_reports_external_configuration_without_values(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"PAPERLESS_OCR_LANGUAGE": "eng",
|
||||
"PAPERLESS_REMOTE_OCR_API_KEY": "secret-value",
|
||||
"PAPERLESS_FUTURE_SETTING": "future-value",
|
||||
"UNRELATED_SETTING": "unrelated-value",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertCountEqual(
|
||||
response.data[0]["externally_configured_variables"],
|
||||
[
|
||||
"PAPERLESS_FUTURE_SETTING",
|
||||
"PAPERLESS_OCR_LANGUAGE",
|
||||
"PAPERLESS_REMOTE_OCR_API_KEY",
|
||||
],
|
||||
)
|
||||
self.assertNotContains(response, "secret-value")
|
||||
self.assertNotContains(response, "future-value")
|
||||
self.assertNotContains(response, "UNRELATED_SETTING")
|
||||
|
||||
def test_api_get_ui_settings_with_config(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -2,12 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
|
||||
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase as DjangoTestCase
|
||||
from django.utils import timezone
|
||||
@@ -20,7 +22,6 @@ from documents.filters import TitleContentFilter
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.views import DocumentSelectionMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -891,104 +892,32 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestVersionAwareFilters(DjangoTestCase):
|
||||
"""
|
||||
The filters annotate effective_content themselves rather than relying on
|
||||
the caller's queryset carrying it, so they stay version-aware on a plain
|
||||
Document queryset (e.g. the bulk-edit "select all matching" path).
|
||||
"""
|
||||
class TestVersionAwareFilters(TestCase):
|
||||
def test_title_content_filter_falls_back_to_content(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
fallback_queryset = mock.Mock()
|
||||
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
mime_type="application/pdf",
|
||||
content="superseded-content",
|
||||
)
|
||||
Document.objects.create(
|
||||
title="version",
|
||||
checksum="version",
|
||||
mime_type="application/pdf",
|
||||
root_document=self.root,
|
||||
version_index=1,
|
||||
content="latest-content",
|
||||
)
|
||||
self.unversioned = Document.objects.create(
|
||||
title="unversioned",
|
||||
checksum="unversioned",
|
||||
mime_type="application/pdf",
|
||||
content="latest-content",
|
||||
)
|
||||
result = TitleContentFilter().filter(queryset, " latest ")
|
||||
|
||||
def test_title_content_filter_matches_latest_version_content(self) -> None:
|
||||
result = TitleContentFilter().filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
self.assertIs(result, fallback_queryset)
|
||||
self.assertEqual(queryset.filter.call_count, 2)
|
||||
|
||||
def test_effective_content_filter_falls_back_to_content_lookup(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
fallback_queryset = mock.Mock()
|
||||
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
queryset,
|
||||
" latest ",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_effective_content_filter_matches_latest_version_content(self) -> None:
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
" latest ",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_effective_content_filter_ignores_superseded_content(self) -> None:
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
"superseded",
|
||||
)
|
||||
|
||||
self.assertEqual(list(result), [])
|
||||
|
||||
def test_filters_reuse_an_existing_annotation(self) -> None:
|
||||
"""
|
||||
Annotating twice under the same alias is an error, so an already
|
||||
annotated queryset (the search path) has to be left alone.
|
||||
"""
|
||||
annotated = annotate_effective_content(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
)
|
||||
self.assertIs(annotate_effective_content(annotated), annotated)
|
||||
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
annotated,
|
||||
"latest",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_bulk_selection_does_not_match_superseded_content(self) -> None:
|
||||
"""
|
||||
Bulk edit's "select all matching" builds its own queryset, so before
|
||||
the filters annotated for themselves it matched the root document's
|
||||
superseded content -- selecting documents the list view, filtered by
|
||||
the same term, does not show.
|
||||
"""
|
||||
user = User.objects.create_superuser(username="bulk_selection")
|
||||
|
||||
selected = DocumentSelectionMixin()._resolve_document_ids(
|
||||
user=user,
|
||||
validated_data={
|
||||
"all": True,
|
||||
"filters": {"content__icontains": "superseded"},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(selected, [])
|
||||
self.assertIs(result, fallback_queryset)
|
||||
first_kwargs = queryset.filter.call_args_list[0].kwargs
|
||||
second_kwargs = queryset.filter.call_args_list[1].kwargs
|
||||
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"})
|
||||
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
|
||||
|
||||
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
|
||||
@@ -1947,29 +1947,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(len(response.data["documents"]), 1)
|
||||
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
|
||||
|
||||
def test_global_search_returns_latest_version_content(self) -> None:
|
||||
root = Document.objects.create(
|
||||
title="bank statement",
|
||||
content="superseded content",
|
||||
checksum="GSV1",
|
||||
pk=23,
|
||||
)
|
||||
Document.objects.create(
|
||||
title="bank statement v2",
|
||||
content="latest content",
|
||||
checksum="GSV2",
|
||||
pk=24,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
response = self.client.get("/api/search/?query=bank&db_only=true")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
|
||||
self.assertEqual(returned.get(root.id), "latest content")
|
||||
|
||||
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||
user1 = User.objects.create_user("mail-search-user")
|
||||
user2 = User.objects.create_user("other-mail-search-user")
|
||||
|
||||
@@ -207,3 +207,65 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
||||
|
||||
def _make_versioned_document(self) -> tuple[Document, list[Document]]:
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
content="root-content",
|
||||
checksum="root",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
versions = [
|
||||
Document.objects.create(
|
||||
title=f"v{index}",
|
||||
content=f"v{index}-content",
|
||||
checksum=f"v{index}",
|
||||
mime_type="application/pdf",
|
||||
root_document=root,
|
||||
version_index=index,
|
||||
)
|
||||
for index in range(1, 3)
|
||||
]
|
||||
return root, versions
|
||||
|
||||
def test_api_trash_restore_document_restores_its_versions(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document with two versions
|
||||
WHEN:
|
||||
- API request to delete the document
|
||||
- API request to restore it from the trash
|
||||
THEN:
|
||||
- Only the document itself is listed in the trash
|
||||
- A version cannot be restored without its root
|
||||
- The document is restored together with all of its versions
|
||||
"""
|
||||
root, versions = self._make_versioned_document()
|
||||
|
||||
self.client.force_login(user=self.user)
|
||||
self.client.delete(f"/api/documents/{root.pk}/")
|
||||
self.assertEqual(Document.deleted_objects.count(), 3)
|
||||
|
||||
resp = self.client.get("/api/trash/")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["count"], 1)
|
||||
self.assertEqual(resp.data["results"][0]["id"], root.pk)
|
||||
|
||||
# A version cannot be restored while its root remains in the trash.
|
||||
resp = self.client.post(
|
||||
"/api/trash/",
|
||||
{"action": "restore", "documents": [versions[0].pk]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("Restore the root document", resp.data["documents"][0])
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/trash/",
|
||||
{"action": "restore", "documents": [root.pk]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(Document.deleted_objects.count(), 0)
|
||||
self.assertCountEqual(
|
||||
Document.objects.filter(root_document=root).values_list("id", flat=True),
|
||||
[version.pk for version in versions],
|
||||
)
|
||||
|
||||
@@ -392,6 +392,11 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
||||
self.assertFalse(Document.objects.filter(id=version.id).exists())
|
||||
|
||||
Document.deleted_objects.get(id=self.doc1.id).restore(strict=False)
|
||||
|
||||
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
|
||||
self.assertTrue(Document.objects.filter(id=version.id).exists())
|
||||
|
||||
def test_delete_version_document_keeps_root(self) -> None:
|
||||
version = Document.objects.create(
|
||||
checksum="A-v1",
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from rest_framework import status
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||
from documents.versioning import has_prefetched_effective_content
|
||||
from documents.versioning import latest_version_content_prefetch
|
||||
from documents.views import DocumentViewSet
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
class TestNeedsEffectiveContentAnnotation:
|
||||
"""
|
||||
DocumentViewSet._needs_effective_content_annotation() decides whether
|
||||
the effective_content correlated subquery is worth attaching to the
|
||||
queryset at all -- see TestDocumentListEffectiveContentAnnotation below
|
||||
for why. This only checks that decision's own logic (a plain query-param
|
||||
membership test), not that Django/DRF's filtering machinery works.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("params", "expected"),
|
||||
[
|
||||
({}, False),
|
||||
({"ordering": "-added"}, False),
|
||||
({"tags__id__in": "1,2"}, False),
|
||||
({"search": ""}, False),
|
||||
({"search": " "}, False),
|
||||
({"content__icontains": ""}, False),
|
||||
({"search": "foo"}, True),
|
||||
({"title_content": "foo"}, True),
|
||||
({"content__istartswith": "foo"}, True),
|
||||
({"content__iendswith": "foo"}, True),
|
||||
({"content__icontains": "foo"}, True),
|
||||
({"content__iexact": "foo"}, True),
|
||||
],
|
||||
)
|
||||
def test_detects_content_filter_params(
|
||||
self,
|
||||
params: dict[str, str],
|
||||
expected: bool, # noqa: FBT001
|
||||
) -> None:
|
||||
# GIVEN a view bound to a request carrying the given query params
|
||||
view = DocumentViewSet()
|
||||
view.request = SimpleNamespace(query_params=params)
|
||||
|
||||
# WHEN checking whether the effective_content annotation is needed
|
||||
# THEN it's needed only for requests that actually filter on it
|
||||
assert view._needs_effective_content_annotation() is expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDocumentListEffectiveContentAnnotation:
|
||||
"""
|
||||
DocumentViewSet.get_queryset() only attaches the effective_content
|
||||
correlated subquery when a request actually filters on it. Attaching it
|
||||
unconditionally re-executes it once per candidate row before the page's
|
||||
LIMIT is applied -- fine on SQLite/Postgres, but pathological on
|
||||
MariaDB's default cardinality estimation for the root_document_id
|
||||
self-join once candidate counts get large (see the root_document_id /
|
||||
effective_content perf investigation).
|
||||
"""
|
||||
|
||||
def test_list_without_content_filter_skips_annotation_but_returns_latest_content(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
) -> None:
|
||||
# GIVEN a root document whose latest version has different content
|
||||
root = DocumentFactory(content="old-root-content")
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="new-version-content",
|
||||
)
|
||||
|
||||
# WHEN listing documents with no search/content-filter param
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = admin_client.get("/api/documents/?fields=id,content")
|
||||
|
||||
# THEN the response still reflects the latest version's content...
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["results"] == [
|
||||
{"id": root.id, "content": "new-version-content"},
|
||||
]
|
||||
# ...without the database ever evaluating effective_content per row
|
||||
assert not any(
|
||||
"effective_content" in query["sql"] for query in ctx.captured_queries
|
||||
)
|
||||
|
||||
def test_latest_version_content_prefetch_carries_only_the_newest_version(
|
||||
self,
|
||||
) -> None:
|
||||
# GIVEN a root document with two versions
|
||||
root = DocumentFactory(content="root-content")
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="older-version-content",
|
||||
)
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=2,
|
||||
content="newest-version-content",
|
||||
)
|
||||
|
||||
# WHEN fetching the root through latest_version_content_prefetch()
|
||||
fetched_root = (
|
||||
Document.objects.filter(pk=root.pk)
|
||||
.prefetch_related(
|
||||
latest_version_content_prefetch(),
|
||||
)
|
||||
.get()
|
||||
)
|
||||
|
||||
# THEN the prefetch carries only the single newest version, not
|
||||
# every historical version's content (the whole point of not
|
||||
# reusing the metadata-only "versions" prefetch for this)
|
||||
latest = getattr(fetched_root, LATEST_VERSION_CONTENT_PREFETCH_ATTR)
|
||||
assert [v.content for v in latest] == ["newest-version-content"]
|
||||
|
||||
|
||||
class TestHasPrefetchedEffectiveContent:
|
||||
"""
|
||||
DocumentSerializer.to_representation() only calls get_effective_content()
|
||||
when has_prefetched_effective_content() says it's cheap -- otherwise a
|
||||
caller that never set up an annotation or prefetch (TrashView,
|
||||
GlobalSearchView, which build their own querysets and don't display
|
||||
content at all) would pay for a per-instance query nobody asked for.
|
||||
"""
|
||||
|
||||
def test_false_with_no_annotation_or_prefetch(self) -> None:
|
||||
document = Document()
|
||||
assert has_prefetched_effective_content(document) is False
|
||||
|
||||
def test_true_with_effective_content_annotation(self) -> None:
|
||||
document = Document()
|
||||
document.effective_content = "resolved"
|
||||
assert has_prefetched_effective_content(document) is True
|
||||
|
||||
def test_true_with_lean_prefetch_attr_even_when_empty(self) -> None:
|
||||
document = Document()
|
||||
setattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, [])
|
||||
assert has_prefetched_effective_content(document) is True
|
||||
|
||||
def test_true_with_metadata_versions_prefetch_cache(self) -> None:
|
||||
document = Document()
|
||||
document._prefetched_objects_cache = {"versions": []}
|
||||
assert has_prefetched_effective_content(document) is True
|
||||
|
||||
|
||||
def _get_effective_content_fallback_queries(
|
||||
ctx: CaptureQueriesContext,
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Document.get_effective_content()'s per-instance fallback (no annotation,
|
||||
no prefetch) is a `.values_list("content", flat=True).first()` query --
|
||||
a SELECT of just the content column. Distinct from get_versions()'s own,
|
||||
unrelated per-instance metadata query (id/checksum/added/etc, no
|
||||
content) run to build the "versions" response field, which isn't part
|
||||
of what this test file covers.
|
||||
"""
|
||||
return [
|
||||
q
|
||||
for q in ctx.captured_queries
|
||||
if q["sql"].startswith('SELECT "documents_document"."content" FROM')
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTrashAndGlobalSearchEffectiveContentIsNeverPerInstance:
|
||||
"""
|
||||
TrashView and GlobalSearchView serialize Document instances with
|
||||
DocumentSerializer too, but build their querysets independently of
|
||||
DocumentViewSet.get_queryset(). TrashView doesn't display content at all,
|
||||
so it keeps the document's own unresolved content; GlobalSearchView
|
||||
annotates effective_content itself, so it shows the latest version's.
|
||||
Neither should ever fall back to a per-instance query.
|
||||
"""
|
||||
|
||||
def test_trash_list_shows_unresolved_content_with_no_extra_query(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
) -> None:
|
||||
# GIVEN a trashed root document whose own content differs from what
|
||||
# a (also trashed, since deletion cascades) version would have had
|
||||
root = DocumentFactory(content="own-content")
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="version-content",
|
||||
)
|
||||
root.delete()
|
||||
|
||||
# WHEN listing trash
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = admin_client.get("/api/trash/")
|
||||
|
||||
# THEN the response shows the document's own content...
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
||||
assert result["content"] == "own-content"
|
||||
# ...without ever querying for versions to resolve it
|
||||
assert _get_effective_content_fallback_queries(ctx) == []
|
||||
|
||||
def test_global_search_db_only_shows_latest_version_content_with_no_extra_query(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
) -> None:
|
||||
# GIVEN a root document, findable by title, whose own content
|
||||
# differs from its latest version's
|
||||
root = DocumentFactory(title="findme", content="own-content")
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="version-content",
|
||||
)
|
||||
|
||||
# WHEN using the global search endpoint's db_only mode
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = admin_client.get(
|
||||
"/api/search/?query=findme&db_only=true",
|
||||
)
|
||||
|
||||
# THEN the response shows the latest version's content, resolved by
|
||||
# GlobalSearchView's own effective_content annotation...
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
[result] = [d for d in response.data["documents"] if d["id"] == root.id]
|
||||
assert result["content"] == "version-content"
|
||||
# ...with no per-instance fallback query
|
||||
assert _get_effective_content_fallback_queries(ctx) == []
|
||||
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
|
||||
checksum="checksum",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
Document.objects.create(
|
||||
version = Document.objects.create(
|
||||
root_document=root,
|
||||
correspondent=root.correspondent,
|
||||
title="Version",
|
||||
@@ -124,6 +124,10 @@ class TestDocument(TestCase):
|
||||
self.assertEqual(Document.objects.count(), 0)
|
||||
self.assertEqual(Document.deleted_objects.count(), 2)
|
||||
|
||||
root.restore(strict=False)
|
||||
|
||||
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
|
||||
|
||||
def test_file_name(self) -> None:
|
||||
doc = Document(
|
||||
mime_type="application/pdf",
|
||||
|
||||
@@ -7,12 +7,9 @@ from typing import Any
|
||||
|
||||
from django.db.models import F
|
||||
from django.db.models import OuterRef
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Subquery
|
||||
from django.db.models import Window
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
@@ -30,13 +27,10 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
|
||||
def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
"""
|
||||
Annotates documents with the content of their newest version unless the
|
||||
queryset already carries the annotation, falling back to their own, so
|
||||
get_effective_content() can answer from the row rather than querying for
|
||||
the versions of each document.
|
||||
Annotates documents with the content of their newest version, falling back
|
||||
to their own, so get_effective_content() can answer from the row rather
|
||||
than querying for the versions of each document
|
||||
"""
|
||||
if "effective_content" in documents.query.annotations:
|
||||
return documents
|
||||
return documents.annotate(
|
||||
effective_content=Coalesce(
|
||||
Subquery(
|
||||
@@ -49,68 +43,6 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
|
||||
)
|
||||
|
||||
|
||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR = "_latest_version_content_prefetch"
|
||||
|
||||
|
||||
def latest_version_content_prefetch() -> Prefetch:
|
||||
"""
|
||||
A Prefetch for Document.versions scoped to just the newest version's
|
||||
content, for get_effective_content()'s fallback when no SQL annotation
|
||||
is present.
|
||||
|
||||
Deliberately not merged into a metadata-only "versions" prefetch (the one
|
||||
used for the serialized versions list): that one fetches every historical
|
||||
version of every document, and pulling full OCR content for versions
|
||||
nobody will read wastes DB transfer/memory at scale. This one is windowed
|
||||
down to a single row per root, then bounded by Prefetch's own IN-list to
|
||||
whatever page/result set it's attached to -- one cheap bulk query total,
|
||||
not one per document and not one per version.
|
||||
"""
|
||||
return Prefetch(
|
||||
"versions",
|
||||
queryset=(
|
||||
Document.objects.filter(
|
||||
root_document_id__isnull=False,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.annotate(
|
||||
rn=Window(
|
||||
RowNumber(),
|
||||
partition_by=F("root_document_id"),
|
||||
order_by=[
|
||||
F("version_index").desc(nulls_last=True),
|
||||
F("id").desc(),
|
||||
],
|
||||
),
|
||||
)
|
||||
.filter(rn=1)
|
||||
.only("id", "root_document_id", "content")
|
||||
),
|
||||
to_attr=LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
||||
)
|
||||
|
||||
|
||||
def has_prefetched_effective_content(document: Document) -> bool:
|
||||
"""
|
||||
True if document.get_effective_content() can answer without an extra
|
||||
per-instance query -- an SQL ``effective_content`` annotation, the lean
|
||||
latest_version_content_prefetch(), or the metadata-only "versions"
|
||||
prefetch is already present on the instance.
|
||||
|
||||
Callers that haven't set any of those up (e.g. views that build their
|
||||
own querysets independently of DocumentViewSet.get_queryset(), like
|
||||
TrashView or GlobalSearchView) intentionally don't pay for version-aware
|
||||
content resolution -- see DocumentSerializer.to_representation(), which
|
||||
uses this to decide whether to call get_effective_content() at all.
|
||||
"""
|
||||
if hasattr(document, "effective_content"):
|
||||
return True
|
||||
if getattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, None) is not None:
|
||||
return True
|
||||
prefetched_cache = getattr(document, "_prefetched_objects_cache", None)
|
||||
return isinstance(prefetched_cache, dict) and "versions" in prefetched_cache
|
||||
|
||||
|
||||
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||
"""
|
||||
Same sorting as versions_newest_first()
|
||||
|
||||
+43
-82
@@ -36,6 +36,7 @@ from django.db.migrations.recorder import MigrationRecorder
|
||||
from django.db.models import Avg
|
||||
from django.db.models import Case
|
||||
from django.db.models import Count
|
||||
from django.db.models import F
|
||||
from django.db.models import IntegerField
|
||||
from django.db.models import Max
|
||||
from django.db.models import Model
|
||||
@@ -136,14 +137,12 @@ from documents.filters import CustomFieldFilterSet
|
||||
from documents.filters import DocumentFilterSet
|
||||
from documents.filters import DocumentsOrderingFilter
|
||||
from documents.filters import DocumentTypeFilterSet
|
||||
from documents.filters import EffectiveContentFilter
|
||||
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
|
||||
from documents.filters import TagFilterSet
|
||||
from documents.filters import TitleContentFilter
|
||||
from documents.mail import EmailAttachment
|
||||
from documents.mail import send_email
|
||||
from documents.matching import match_correspondents
|
||||
@@ -233,11 +232,9 @@ from documents.tasks import train_classifier
|
||||
from documents.tasks import update_document_parent_tags
|
||||
from documents.utils import get_boolean
|
||||
from documents.versioning import VersionResolutionError
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_request_version_param
|
||||
from documents.versioning import get_root_document
|
||||
from documents.versioning import latest_version_content_prefetch
|
||||
from documents.versioning import resolve_requested_version_for_root
|
||||
from documents.versioning import versions_newest_first
|
||||
from paperless import version
|
||||
@@ -1085,49 +1082,12 @@ class DocumentViewSet(
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _content_filter_params(cls) -> tuple[str, ...]:
|
||||
"""
|
||||
Query params whose filtering needs effective_content evaluated in SQL
|
||||
against every candidate row -- see
|
||||
_needs_effective_content_annotation(). Derived rather than
|
||||
hand-maintained so a new content-filtering param counts automatically.
|
||||
"""
|
||||
params = [
|
||||
name
|
||||
for name, f in DocumentFilterSet.declared_filters.items()
|
||||
if isinstance(f, (TitleContentFilter, EffectiveContentFilter))
|
||||
]
|
||||
if "effective_content" in cls.search_fields:
|
||||
params.append(SearchFilter().search_param)
|
||||
return tuple(params)
|
||||
|
||||
def _needs_effective_content_annotation(self) -> bool:
|
||||
# effective_content is a per-row correlated subquery resolving each
|
||||
# document's latest version. Filtering *on* it forces the database to
|
||||
# evaluate it for every candidate row before reaching the LIMIT, which
|
||||
# the root_document_id self-join makes pathological on MariaDB
|
||||
# specifically once real candidate counts get large; otherwise the
|
||||
# "versions" prefetch + Document.get_effective_content() resolves only
|
||||
# the page that survives pagination. Every param here is deprecated in
|
||||
# favor of the Tantivy-backed search endpoint (see filters.py's
|
||||
# TitleContentFilter/EffectiveContentFilter docs), so pay that cost
|
||||
# only when one is actually used. Blank values don't count, matching
|
||||
# how those filters themselves no-op on them -- an empty `?search=`
|
||||
# applies no predicate.
|
||||
params = self.request.query_params
|
||||
return any(
|
||||
params.get(param, "").strip() for param in self._content_filter_params()
|
||||
)
|
||||
|
||||
def _needs_effective_content_prefetch(self) -> bool:
|
||||
# The prefetch spares get_effective_content() a per-instance fallback
|
||||
# query, but only earns itself when content can reach the response.
|
||||
# Mirror get_serializer() below: no `fields` param keeps every field.
|
||||
fields_param = self.request.query_params.get("fields", None)
|
||||
return fields_param is None or "content" in fields_param.split(",")
|
||||
|
||||
def get_queryset(self):
|
||||
latest_version_content = Subquery(
|
||||
versions_newest_first(
|
||||
Document.objects.filter(root_document=OuterRef("pk")),
|
||||
).values("content")[:1],
|
||||
)
|
||||
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
||||
# be, which forced a GROUP BY aggregate over every matching document
|
||||
# before the query could even be sorted or limited.
|
||||
@@ -1147,38 +1107,33 @@ class DocumentViewSet(
|
||||
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
||||
# database to fully sort and dedupe every visible document before
|
||||
# it can apply LIMIT, which is disastrous at scale.
|
||||
prefetches = [
|
||||
Prefetch(
|
||||
"versions",
|
||||
queryset=Document.objects.only(
|
||||
"id",
|
||||
"added",
|
||||
"checksum",
|
||||
"version_label",
|
||||
"root_document_id",
|
||||
"version_index",
|
||||
),
|
||||
),
|
||||
"tags",
|
||||
Prefetch(
|
||||
"custom_fields",
|
||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||
),
|
||||
# NotesSerializer nests the author, this avoids query per note
|
||||
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||
]
|
||||
if self._needs_effective_content_prefetch():
|
||||
prefetches.append(latest_version_content_prefetch())
|
||||
queryset = (
|
||||
return (
|
||||
Document.objects.filter(root_document__isnull=True)
|
||||
.order_by("-created", "-id")
|
||||
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
|
||||
.annotate(num_notes=Coalesce(note_count, 0))
|
||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||
.prefetch_related(*prefetches)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"versions",
|
||||
queryset=Document.objects.only(
|
||||
"id",
|
||||
"added",
|
||||
"checksum",
|
||||
"version_label",
|
||||
"root_document_id",
|
||||
"version_index",
|
||||
),
|
||||
),
|
||||
"tags",
|
||||
Prefetch(
|
||||
"custom_fields",
|
||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||
),
|
||||
# NotesSerializer nests the author, this avoids query per note
|
||||
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||
)
|
||||
)
|
||||
if self._needs_effective_content_annotation():
|
||||
queryset = annotate_effective_content(queryset)
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
fields_param = self.request.query_params.get("fields", None)
|
||||
@@ -3677,13 +3632,8 @@ class GlobalSearchView(PassUserMixin):
|
||||
OBJECT_LIMIT = 3
|
||||
docs = []
|
||||
if request.user.has_perm("documents.view_document"):
|
||||
# Never more than OBJECT_LIMIT rows come back here, so annotating
|
||||
# is cheap -- and without it these results show the root
|
||||
# document's superseded content.
|
||||
all_docs = annotate_effective_content(
|
||||
Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
),
|
||||
all_docs = Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
)
|
||||
if db_only:
|
||||
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
|
||||
@@ -5481,7 +5431,10 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
|
||||
model = Document
|
||||
|
||||
queryset = Document.deleted_objects.all()
|
||||
# A version is listed separately only when its root is not in the trash.
|
||||
queryset = Document.deleted_objects.exclude(
|
||||
root_document_id__in=Document.deleted_objects.values("id"),
|
||||
)
|
||||
|
||||
def get(self, request: Request, format: str | None = None) -> Response:
|
||||
self.serializer_class = DocumentSerializer
|
||||
@@ -5512,7 +5465,15 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
action = serializer.validated_data.get("action")
|
||||
if action == "restore":
|
||||
restored = list(Document.deleted_objects.filter(id__in=doc_ids))
|
||||
restored = list(self.get_queryset().filter(id__in=doc_ids))
|
||||
if len(restored) != len(doc_ids):
|
||||
raise ValidationError(
|
||||
{
|
||||
"documents": [
|
||||
"Restore the root document instead of one of its versions.",
|
||||
],
|
||||
},
|
||||
)
|
||||
for doc in restored:
|
||||
doc.restore(strict=False)
|
||||
if restored:
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-08 15:56+0000\n"
|
||||
"POT-Creation-Date: 2026-09-07 20:47+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -21,39 +21,39 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:463
|
||||
#: documents/filters.py:473
|
||||
msgid "Value must be valid JSON."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:482
|
||||
#: documents/filters.py:492
|
||||
msgid "Invalid custom field query expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:492
|
||||
#: documents/filters.py:502
|
||||
msgid "Invalid expression list. Must be nonempty."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:513
|
||||
#: documents/filters.py:523
|
||||
msgid "Invalid logical operator {op!r}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:527
|
||||
#: documents/filters.py:537
|
||||
msgid "Maximum number of query conditions exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:591
|
||||
#: documents/filters.py:601
|
||||
msgid "{name!r} is not a valid custom field."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:628
|
||||
#: documents/filters.py:638
|
||||
msgid "{data_type} does not support query expr {expr!r}."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:747 documents/models.py:136
|
||||
#: documents/filters.py:757 documents/models.py:136
|
||||
msgid "Maximum nesting depth exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:1109
|
||||
#: documents/filters.py:1119
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1631,49 +1631,49 @@ msgstr ""
|
||||
msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:524 documents/serialisers.py:881
|
||||
#: documents/serialisers.py:2841 documents/views.py:315 documents/views.py:2625
|
||||
#: documents/serialisers.py:524 documents/serialisers.py:878
|
||||
#: documents/serialisers.py:2838 documents/views.py:314 documents/views.py:2624
|
||||
#: paperless_mail/serialisers.py:156
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:717
|
||||
#: documents/serialisers.py:714
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2318
|
||||
#: documents/serialisers.py:2315
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2362
|
||||
#: documents/serialisers.py:2359
|
||||
#, python-format
|
||||
msgid "Custom field id must be an integer: %(id)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2369
|
||||
#: documents/serialisers.py:2366
|
||||
#, python-format
|
||||
msgid "Custom field with id %(id)s does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2386 documents/serialisers.py:2396
|
||||
#: documents/serialisers.py:2383 documents/serialisers.py:2393
|
||||
msgid ""
|
||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2391
|
||||
#: documents/serialisers.py:2388
|
||||
msgid "Some custom fields don't exist or were specified twice."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2538
|
||||
#: documents/serialisers.py:2535
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2897
|
||||
#: documents/serialisers.py:2894
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2927 documents/views.py:4632
|
||||
#: documents/serialisers.py:2924 documents/views.py:4626
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1941,36 +1941,36 @@ msgstr ""
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:308 documents/views.py:2622
|
||||
#: documents/views.py:307 documents/views.py:2621
|
||||
msgid "Invalid more_like_id"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1592
|
||||
#: documents/views.py:1591
|
||||
msgid "Invalid AI configuration."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:1603
|
||||
#: documents/views.py:1602
|
||||
msgid "AI backend request timed out."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2447 documents/views.py:2768
|
||||
#: documents/views.py:2446 documents/views.py:2767
|
||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4645
|
||||
#: documents/views.py:4639
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4691
|
||||
#: documents/views.py:4685
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4755
|
||||
#: documents/views.py:4749
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4769
|
||||
#: documents/views.py:4763
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
import magic
|
||||
@@ -213,7 +212,6 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[Use
|
||||
class ApplicationConfigurationSerializer(
|
||||
serializers.ModelSerializer[ApplicationConfiguration],
|
||||
):
|
||||
externally_configured_variables = serializers.SerializerMethodField()
|
||||
user_args = serializers.JSONField(binary=True, allow_null=True)
|
||||
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
||||
llm_api_key = ObfuscatedPasswordField(
|
||||
@@ -229,12 +227,6 @@ class ApplicationConfigurationSerializer(
|
||||
|
||||
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
|
||||
|
||||
def get_externally_configured_variables(
|
||||
self,
|
||||
instance: ApplicationConfiguration,
|
||||
) -> list[str]:
|
||||
return sorted(name for name in os.environ if name.startswith("PAPERLESS_"))
|
||||
|
||||
def run_validation(self, data):
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
if "user_args" in data and data["user_args"] == "":
|
||||
|
||||
Reference in New Issue
Block a user