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.'
|
'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. ' +
|
: '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' +
|
'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 ' +
|
'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}).`;
|
`contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ jobs:
|
|||||||
pr-bot:
|
pr-bot:
|
||||||
name: Automated PR Bot
|
name: Automated PR Bot
|
||||||
runs-on: ubuntu-latest
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -103,25 +99,8 @@ jobs:
|
|||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const user = context.payload.pull_request.user.login;
|
const pr = context.payload.pull_request;
|
||||||
|
const user = pr.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 { data: members } = await github.rest.orgs.listMembers({
|
const { data: members } = await github.rest.orgs.listMembers({
|
||||||
org: 'paperless-ngx',
|
org: 'paperless-ngx',
|
||||||
|
|||||||
+11
-39
@@ -501,43 +501,15 @@
|
|||||||
<context context-type="linenumber">30</context>
|
<context context-type="linenumber">30</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="7808756054397155068" datatype="html">
|
||||||
<source>Reset</source>
|
<source>Reset</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
@@ -548,7 +520,7 @@
|
|||||||
<source>Enable</source>
|
<source>Enable</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
|
||||||
@@ -559,7 +531,7 @@
|
|||||||
<source>Cancel</source>
|
<source>Cancel</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
@@ -638,7 +610,7 @@
|
|||||||
<source>Save</source>
|
<source>Save</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
|
||||||
@@ -709,42 +681,42 @@
|
|||||||
<source>Error retrieving config</source>
|
<source>Error retrieving config</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1172622527269118932" datatype="html">
|
<trans-unit id="1172622527269118932" datatype="html">
|
||||||
<source>Invalid JSON</source>
|
<source>Invalid JSON</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5103146006962696736" datatype="html">
|
<trans-unit id="5103146006962696736" datatype="html">
|
||||||
<source>Configuration updated</source>
|
<source>Configuration updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1664963291286452273" datatype="html">
|
<trans-unit id="1664963291286452273" datatype="html">
|
||||||
<source>An error occurred updating configuration</source>
|
<source>An error occurred updating configuration</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2653081282186526824" datatype="html">
|
<trans-unit id="2653081282186526824" datatype="html">
|
||||||
<source>File successfully updated</source>
|
<source>File successfully updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5902783625859504265" datatype="html">
|
<trans-unit id="5902783625859504265" datatype="html">
|
||||||
<source>An error occurred uploading file</source>
|
<source>An error occurred uploading file</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4804785061014590286" datatype="html">
|
<trans-unit id="4804785061014590286" datatype="html">
|
||||||
|
|||||||
@@ -23,30 +23,17 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card bg-light">
|
<div class="card bg-light">
|
||||||
<div class="card-body">
|
<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">
|
<h6 class="mb-0">
|
||||||
{{option.title}}
|
{{option.title}}
|
||||||
</h6>
|
</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">
|
<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>
|
<i-bs name="info-circle"></i-bs>
|
||||||
</a>
|
</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 (isSet(option.key)) {
|
||||||
@if (isExternallyConfigured(option.config_key)) {
|
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.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</ng-container>
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
|
</button>
|
||||||
</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>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-n3">
|
<div class="mb-n3">
|
||||||
|
|||||||
@@ -163,19 +163,6 @@ describe('ConfigComponent', () => {
|
|||||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
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', () => {
|
it('should group options into sections within a category, or not', () => {
|
||||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export class ConfigComponent
|
|||||||
public configForm = new FormGroup({})
|
public configForm = new FormGroup({})
|
||||||
|
|
||||||
public errors = {}
|
public errors = {}
|
||||||
public externallyConfiguredVariables = new Set<string>()
|
|
||||||
|
|
||||||
get optionCategories(): string[] {
|
get optionCategories(): string[] {
|
||||||
return Object.values(ConfigCategory)
|
return Object.values(ConfigCategory)
|
||||||
@@ -153,9 +152,6 @@ export class ConfigComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initialize(config: PaperlessConfig) {
|
private initialize(config: PaperlessConfig) {
|
||||||
this.externallyConfiguredVariables = new Set(
|
|
||||||
config.externally_configured_variables ?? []
|
|
||||||
)
|
|
||||||
if (!this.store) {
|
if (!this.store) {
|
||||||
this.store = new BehaviorSubject(config)
|
this.store = new BehaviorSubject(config)
|
||||||
|
|
||||||
@@ -166,9 +162,7 @@ export class ConfigComponent
|
|||||||
this.configForm.patchValue(state, { emitEvent: false })
|
this.configForm.patchValue(state, { emitEvent: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
|
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||||
excludeKeys: ['externally_configured_variables'],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
this.configForm.patchValue(config)
|
this.configForm.patchValue(config)
|
||||||
|
|
||||||
@@ -233,10 +227,6 @@ export class ConfigComponent
|
|||||||
return this.configForm.get(key).value != null
|
return this.configForm.get(key).value != null
|
||||||
}
|
}
|
||||||
|
|
||||||
public isExternallyConfigured(configKey: string): boolean {
|
|
||||||
return this.externallyConfiguredVariables.has(configKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
public resetOption(key: string) {
|
public resetOption(key: string) {
|
||||||
this.configForm.get(key).setValue(null)
|
this.configForm.get(key).setValue(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,7 +422,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export interface PaperlessConfig extends ObjectWithId {
|
export interface PaperlessConfig extends ObjectWithId {
|
||||||
externally_configured_variables: string[]
|
|
||||||
output_type: OutputTypeConfig
|
output_type: OutputTypeConfig
|
||||||
pages: number
|
pages: number
|
||||||
language: string
|
language: string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -379,7 +380,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
)
|
)
|
||||||
delete_ids = list({*doc_ids, *version_ids})
|
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
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import FieldError
|
||||||
from django.db.models import Case
|
from django.db.models import Case
|
||||||
from django.db.models import CharField
|
from django.db.models import CharField
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
@@ -52,7 +53,6 @@ from documents.models import StoragePath
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.versioning import annotate_effective_content
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -182,9 +182,14 @@ class TitleContentFilter(Filter):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
|
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
|
||||||
)
|
)
|
||||||
return annotate_effective_content(qs).filter(
|
try:
|
||||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
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:
|
else:
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -195,9 +200,14 @@ class EffectiveContentFilter(Filter):
|
|||||||
value = value.strip() if isinstance(value, str) else value
|
value = value.strip() if isinstance(value, str) else value
|
||||||
if not value:
|
if not value:
|
||||||
return qs
|
return qs
|
||||||
return annotate_effective_content(qs).filter(
|
try:
|
||||||
**{f"effective_content__{self.lookup_expr}": value},
|
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)
|
@extend_schema_field(serializers.BooleanField)
|
||||||
|
|||||||
+10
-2
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -514,13 +515,20 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
*args,
|
*args,
|
||||||
|
transaction_id=None,
|
||||||
**kwargs,
|
**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:
|
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(
|
return super().delete(
|
||||||
*args,
|
*args,
|
||||||
|
transaction_id=transaction_id,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -674,9 +674,6 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
|||||||
ordering = ordering or (Lower("name"),)
|
ordering = ordering or (Lower("name"),)
|
||||||
children = children.order_by(*ordering)
|
children = children.order_by(*ordering)
|
||||||
|
|
||||||
if not children:
|
|
||||||
return []
|
|
||||||
|
|
||||||
serializer = TagSerializer(
|
serializer = TagSerializer(
|
||||||
children,
|
children,
|
||||||
many=True,
|
many=True,
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- Existing config
|
- 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)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
@@ -46,7 +45,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
response.data[0],
|
response.data[0],
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"externally_configured_variables": [],
|
|
||||||
"output_type": None,
|
"output_type": None,
|
||||||
"pages": None,
|
"pages": None,
|
||||||
"language": 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:
|
def test_api_get_ui_settings_with_config(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest import TestCase
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import FieldError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase as DjangoTestCase
|
from django.test import TestCase as DjangoTestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -20,7 +22,6 @@ from documents.filters import TitleContentFilter
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from documents.tests.utils import read_streaming_response
|
||||||
from documents.versioning import annotate_effective_content
|
|
||||||
from documents.views import DocumentSelectionMixin
|
from documents.views import DocumentSelectionMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -891,104 +892,32 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestVersionAwareFilters(DjangoTestCase):
|
class TestVersionAwareFilters(TestCase):
|
||||||
"""
|
def test_title_content_filter_falls_back_to_content(self) -> None:
|
||||||
The filters annotate effective_content themselves rather than relying on
|
queryset = mock.Mock()
|
||||||
the caller's queryset carrying it, so they stay version-aware on a plain
|
fallback_queryset = mock.Mock()
|
||||||
Document queryset (e.g. the bulk-edit "select all matching" path).
|
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
result = TitleContentFilter().filter(queryset, " latest ")
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_title_content_filter_matches_latest_version_content(self) -> None:
|
self.assertIs(result, fallback_queryset)
|
||||||
result = TitleContentFilter().filter(
|
self.assertEqual(queryset.filter.call_count, 2)
|
||||||
Document.objects.filter(root_document__isnull=True),
|
|
||||||
|
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 ",
|
" latest ",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertCountEqual(
|
self.assertIs(result, fallback_queryset)
|
||||||
[doc.id for doc in result],
|
first_kwargs = queryset.filter.call_args_list[0].kwargs
|
||||||
[self.root.id, self.unversioned.id],
|
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_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, [])
|
|
||||||
|
|
||||||
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
|
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
|
||||||
queryset = mock.Mock()
|
queryset = mock.Mock()
|
||||||
|
|||||||
@@ -1947,29 +1947,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(len(response.data["documents"]), 1)
|
self.assertEqual(len(response.data["documents"]), 1)
|
||||||
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
|
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:
|
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||||
user1 = User.objects.create_user("mail-search-user")
|
user1 = User.objects.create_user("mail-search-user")
|
||||||
user2 = User.objects.create_user("other-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.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
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=self.doc1.id).exists())
|
||||||
self.assertFalse(Document.objects.filter(id=version.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:
|
def test_delete_version_document_keeps_root(self) -> None:
|
||||||
version = Document.objects.create(
|
version = Document.objects.create(
|
||||||
checksum="A-v1",
|
checksum="A-v1",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
|
|||||||
checksum="checksum",
|
checksum="checksum",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
Document.objects.create(
|
version = Document.objects.create(
|
||||||
root_document=root,
|
root_document=root,
|
||||||
correspondent=root.correspondent,
|
correspondent=root.correspondent,
|
||||||
title="Version",
|
title="Version",
|
||||||
@@ -124,6 +124,10 @@ class TestDocument(TestCase):
|
|||||||
self.assertEqual(Document.objects.count(), 0)
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
self.assertEqual(Document.deleted_objects.count(), 2)
|
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:
|
def test_file_name(self) -> None:
|
||||||
doc = Document(
|
doc = Document(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
|
|||||||
@@ -27,13 +27,10 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
|||||||
|
|
||||||
def annotate_effective_content(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
|
Annotates documents with the content of their newest version, falling back
|
||||||
queryset already carries the annotation, falling back to their own, so
|
to their own, so get_effective_content() can answer from the row rather
|
||||||
get_effective_content() can answer from the row rather than querying for
|
than querying for the versions of each document
|
||||||
the versions of each document.
|
|
||||||
"""
|
"""
|
||||||
if "effective_content" in documents.query.annotations:
|
|
||||||
return documents
|
|
||||||
return documents.annotate(
|
return documents.annotate(
|
||||||
effective_content=Coalesce(
|
effective_content=Coalesce(
|
||||||
Subquery(
|
Subquery(
|
||||||
|
|||||||
+15
-10
@@ -232,7 +232,6 @@ from documents.tasks import train_classifier
|
|||||||
from documents.tasks import update_document_parent_tags
|
from documents.tasks import update_document_parent_tags
|
||||||
from documents.utils import get_boolean
|
from documents.utils import get_boolean
|
||||||
from documents.versioning import VersionResolutionError
|
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_latest_version_for_root
|
||||||
from documents.versioning import get_request_version_param
|
from documents.versioning import get_request_version_param
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
@@ -3633,13 +3632,8 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
OBJECT_LIMIT = 3
|
OBJECT_LIMIT = 3
|
||||||
docs = []
|
docs = []
|
||||||
if request.user.has_perm("documents.view_document"):
|
if request.user.has_perm("documents.view_document"):
|
||||||
# Never more than OBJECT_LIMIT rows come back here, so annotating
|
all_docs = Document.objects.filter(
|
||||||
# is cheap -- and without it these results show the root
|
id__in=permitted_document_ids(request.user),
|
||||||
# document's superseded content.
|
|
||||||
all_docs = annotate_effective_content(
|
|
||||||
Document.objects.filter(
|
|
||||||
id__in=permitted_document_ids(request.user),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if db_only:
|
if db_only:
|
||||||
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
|
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
|
||||||
@@ -5437,7 +5431,10 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
|
|
||||||
model = Document
|
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:
|
def get(self, request: Request, format: str | None = None) -> Response:
|
||||||
self.serializer_class = DocumentSerializer
|
self.serializer_class = DocumentSerializer
|
||||||
@@ -5468,7 +5465,15 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
action = serializer.validated_data.get("action")
|
action = serializer.validated_data.get("action")
|
||||||
if action == "restore":
|
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:
|
for doc in restored:
|
||||||
doc.restore(strict=False)
|
doc.restore(strict=False)
|
||||||
if restored:
|
if restored:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-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"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -21,39 +21,39 @@ msgstr ""
|
|||||||
msgid "Documents"
|
msgid "Documents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:463
|
#: documents/filters.py:473
|
||||||
msgid "Value must be valid JSON."
|
msgid "Value must be valid JSON."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:482
|
#: documents/filters.py:492
|
||||||
msgid "Invalid custom field query expression"
|
msgid "Invalid custom field query expression"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:492
|
#: documents/filters.py:502
|
||||||
msgid "Invalid expression list. Must be nonempty."
|
msgid "Invalid expression list. Must be nonempty."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:513
|
#: documents/filters.py:523
|
||||||
msgid "Invalid logical operator {op!r}"
|
msgid "Invalid logical operator {op!r}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:527
|
#: documents/filters.py:537
|
||||||
msgid "Maximum number of query conditions exceeded."
|
msgid "Maximum number of query conditions exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:591
|
#: documents/filters.py:601
|
||||||
msgid "{name!r} is not a valid custom field."
|
msgid "{name!r} is not a valid custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:628
|
#: documents/filters.py:638
|
||||||
msgid "{data_type} does not support query expr {expr!r}."
|
msgid "{data_type} does not support query expr {expr!r}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:747 documents/models.py:136
|
#: documents/filters.py:757 documents/models.py:136
|
||||||
msgid "Maximum nesting depth exceeded."
|
msgid "Maximum nesting depth exceeded."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/filters.py:1109
|
#: documents/filters.py:1119
|
||||||
msgid "Custom field not found"
|
msgid "Custom field not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1631,49 +1631,49 @@ msgstr ""
|
|||||||
msgid "workflow runs"
|
msgid "workflow runs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:524 documents/serialisers.py:881
|
#: documents/serialisers.py:524 documents/serialisers.py:878
|
||||||
#: documents/serialisers.py:2841 documents/views.py:315 documents/views.py:2625
|
#: documents/serialisers.py:2838 documents/views.py:314 documents/views.py:2624
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:717
|
#: documents/serialisers.py:714
|
||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2318
|
#: documents/serialisers.py:2315
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2362
|
#: documents/serialisers.py:2359
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2369
|
#: documents/serialisers.py:2366
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2386 documents/serialisers.py:2396
|
#: documents/serialisers.py:2383 documents/serialisers.py:2393
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2391
|
#: documents/serialisers.py:2388
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2538
|
#: documents/serialisers.py:2535
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2897
|
#: documents/serialisers.py:2894
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2927 documents/views.py:4632
|
#: documents/serialisers.py:2924 documents/views.py:4626
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1941,36 +1941,36 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:308 documents/views.py:2622
|
#: documents/views.py:307 documents/views.py:2621
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1592
|
#: documents/views.py:1591
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1603
|
#: documents/views.py:1602
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
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."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4645
|
#: documents/views.py:4639
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4691
|
#: documents/views.py:4685
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4755
|
#: documents/views.py:4749
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4769
|
#: documents/views.py:4763
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
@@ -213,7 +212,6 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[Use
|
|||||||
class ApplicationConfigurationSerializer(
|
class ApplicationConfigurationSerializer(
|
||||||
serializers.ModelSerializer[ApplicationConfiguration],
|
serializers.ModelSerializer[ApplicationConfiguration],
|
||||||
):
|
):
|
||||||
externally_configured_variables = serializers.SerializerMethodField()
|
|
||||||
user_args = serializers.JSONField(binary=True, allow_null=True)
|
user_args = serializers.JSONField(binary=True, allow_null=True)
|
||||||
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
||||||
llm_api_key = ObfuscatedPasswordField(
|
llm_api_key = ObfuscatedPasswordField(
|
||||||
@@ -229,12 +227,6 @@ class ApplicationConfigurationSerializer(
|
|||||||
|
|
||||||
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
|
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):
|
def run_validation(self, data):
|
||||||
# Empty strings treated as None to avoid unexpected behavior
|
# Empty strings treated as None to avoid unexpected behavior
|
||||||
if "user_args" in data and data["user_args"] == "":
|
if "user_args" in data and data["user_args"] == "":
|
||||||
|
|||||||
Reference in New Issue
Block a user