Compare commits

..
18 changed files with 597 additions and 432 deletions
+8
View File
@@ -1209,6 +1209,14 @@ left unassigned, preventing low-confidence guesses from being applied.
Defaults to 0.6.
#### [`PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS=<float>`](#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS) {#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS}
: Sets the timeout, in seconds, for regular expression matching. Increase this
value if date parsing or user-defined matching rules time out when processing
long documents, especially on slower hardware.
Defaults to 0.1 seconds.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
: Specifies which language Paperless should use when parsing dates from documents.
+105 -73
View File
@@ -1345,7 +1345,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1808</context>
<context context-type="linenumber">1865</context>
</context-group>
</trans-unit>
<trans-unit id="1577733187050997705" datatype="html">
@@ -2423,7 +2423,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">673</context>
<context context-type="linenumber">690</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
@@ -3330,11 +3330,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1422</context>
<context context-type="linenumber">1479</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1809</context>
<context context-type="linenumber">1866</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3935,7 +3935,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1375</context>
<context context-type="linenumber">1432</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -4079,7 +4079,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1862</context>
<context context-type="linenumber">1919</context>
</context-group>
</trans-unit>
<trans-unit id="6661109599266152398" datatype="html">
@@ -4090,7 +4090,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1863</context>
<context context-type="linenumber">1920</context>
</context-group>
</trans-unit>
<trans-unit id="5162686434580248853" datatype="html">
@@ -4101,7 +4101,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1864</context>
<context context-type="linenumber">1921</context>
</context-group>
</trans-unit>
<trans-unit id="6665634854532231106" datatype="html">
@@ -6238,7 +6238,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1379</context>
<context context-type="linenumber">1436</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8662,152 +8662,203 @@
<source>Error retrieving metadata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">440</context>
<context context-type="linenumber">442</context>
</context-group>
</trans-unit>
<trans-unit id="2218903673684131427" datatype="html">
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">542,544</context>
<context context-type="linenumber">545,547</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1000,1002</context>
<context context-type="linenumber">1024,1026</context>
</context-group>
</trans-unit>
<trans-unit id="6357361810318120957" datatype="html">
<source>Document was updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">668</context>
<context context-type="linenumber">685</context>
</context-group>
</trans-unit>
<trans-unit id="5154064822428631306" datatype="html">
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">669</context>
<context context-type="linenumber">686</context>
</context-group>
</trans-unit>
<trans-unit id="8462497568316256794" datatype="html">
<source>Reload to discard your local unsaved edits and load the latest remote version.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">670</context>
<context context-type="linenumber">687</context>
</context-group>
</trans-unit>
<trans-unit id="7967484035994732534" datatype="html">
<source>Reload</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">672</context>
<context context-type="linenumber">689</context>
</context-group>
</trans-unit>
<trans-unit id="2907037627372942104" datatype="html">
<source>Document reloaded with latest changes.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">728</context>
<context context-type="linenumber">745</context>
</context-group>
</trans-unit>
<trans-unit id="6435639868943916539" datatype="html">
<source>Document reloaded.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">739</context>
<context context-type="linenumber">756</context>
</context-group>
</trans-unit>
<trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">841</context>
<context context-type="linenumber">858</context>
</context-group>
</trans-unit>
<trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">851</context>
<context context-type="linenumber">868</context>
</context-group>
</trans-unit>
<trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">859</context>
<context context-type="linenumber">876</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">143</context>
<context context-type="linenumber">151</context>
</context-group>
</trans-unit>
<trans-unit id="8229691481345614469" datatype="html">
<source>Save document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">866</context>
<context context-type="linenumber">883</context>
</context-group>
</trans-unit>
<trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">875</context>
<context context-type="linenumber">892</context>
</context-group>
</trans-unit>
<trans-unit id="7427704425579737895" datatype="html">
<source>Error retrieving version content</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">983</context>
<context context-type="linenumber">1006</context>
</context-group>
</trans-unit>
<trans-unit id="159901853873315050" datatype="html">
<source>Unsaved Changes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1050</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">143</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="5905048729613762185" datatype="html">
<source>You have unsaved changes to the content of this version.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1051</context>
</context-group>
</trans-unit>
<trans-unit id="85184271222513014" datatype="html">
<source>Switching versions will discard them.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1052</context>
</context-group>
</trans-unit>
<trans-unit id="2565707334844767610" datatype="html">
<source>Discard and switch</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1054</context>
</context-group>
</trans-unit>
<trans-unit id="2109314380040637387" datatype="html">
<source>Save and switch</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1056</context>
</context-group>
</trans-unit>
<trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1043</context>
<context context-type="linenumber">1099</context>
</context-group>
</trans-unit>
<trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1255</context>
<context context-type="linenumber">1311</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1282</context>
<context context-type="linenumber">1339</context>
</context-group>
</trans-unit>
<trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document().title"/>&quot;</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1288</context>
<context context-type="linenumber">1345</context>
</context-group>
</trans-unit>
<trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1343</context>
<context context-type="linenumber">1400</context>
</context-group>
</trans-unit>
<trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document().title"/>&quot; to the trash?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1376</context>
<context context-type="linenumber">1433</context>
</context-group>
</trans-unit>
<trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1377</context>
<context context-type="linenumber">1434</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8818,14 +8869,14 @@
<source>Error deleting document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1398</context>
<context context-type="linenumber">1455</context>
</context-group>
</trans-unit>
<trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1418</context>
<context context-type="linenumber">1475</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8836,102 +8887,102 @@
<source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1419</context>
<context context-type="linenumber">1476</context>
</context-group>
</trans-unit>
<trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1420</context>
<context context-type="linenumber">1477</context>
</context-group>
</trans-unit>
<trans-unit id="4700389117298802932" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1433</context>
<context context-type="linenumber">1490</context>
</context-group>
</trans-unit>
<trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1444</context>
<context context-type="linenumber">1501</context>
</context-group>
</trans-unit>
<trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1508</context>
<context context-type="linenumber">1565</context>
</context-group>
</trans-unit>
<trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1586</context>
<context context-type="linenumber">1643</context>
</context-group>
</trans-unit>
<trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1829</context>
<context context-type="linenumber">1886</context>
</context-group>
</trans-unit>
<trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1841</context>
<context context-type="linenumber">1898</context>
</context-group>
</trans-unit>
<trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1852</context>
<context context-type="linenumber">1909</context>
</context-group>
</trans-unit>
<trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document().title"/>&quot; will begin in the background.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1886</context>
<context context-type="linenumber">1943</context>
</context-group>
</trans-unit>
<trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1900</context>
<context context-type="linenumber">1957</context>
</context-group>
</trans-unit>
<trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1950</context>
<context context-type="linenumber">2007</context>
</context-group>
</trans-unit>
<trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1959</context>
<context context-type="linenumber">2016</context>
</context-group>
</trans-unit>
<trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2042</context>
<context context-type="linenumber">2099</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2048</context>
<context context-type="linenumber">2105</context>
</context-group>
</trans-unit>
<trans-unit id="4958946940233632319" datatype="html">
@@ -12290,25 +12341,6 @@
<context context-type="linenumber">16</context>
</context-group>
</trans-unit>
<trans-unit id="159901853873315050" datatype="html">
<source>Unsaved Changes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-form.guard.ts</context>
<context context-type="linenumber">15</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">135</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">162</context>
</context-group>
</trans-unit>
<trans-unit id="2573823578527613511" datatype="html">
<source>You have unsaved changes.</source>
<context-group purpose="location">
@@ -12317,7 +12349,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">163</context>
<context context-type="linenumber">171</context>
</context-group>
</trans-unit>
<trans-unit id="3305084982600522070" datatype="html">
@@ -12457,28 +12489,28 @@
<source>You have unsaved changes to the document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">137</context>
<context context-type="linenumber">145</context>
</context-group>
</trans-unit>
<trans-unit id="2089045849587358256" datatype="html">
<source>Are you sure you want to close this document?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">141</context>
<context context-type="linenumber">149</context>
</context-group>
</trans-unit>
<trans-unit id="6755718693176327396" datatype="html">
<source>Are you sure you want to close all documents?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">164</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="4215561719980781894" datatype="html">
<source>Close documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
<context context-type="linenumber">166</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="1206520795340730278" datatype="html">
@@ -28,8 +28,9 @@ import { Subject, of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { DataType } from 'src/app/data/datatype'
import { Document } from 'src/app/data/document'
import { Document, DocumentVersionInfo } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
import {
FILTER_CORRESPONDENT,
@@ -100,13 +101,18 @@ const doc: Document = {
custom_fields: [
{
field: 0,
document: 3,
created: new Date(),
value: 'custom foo bar',
},
],
] as CustomFieldInstance[],
}
// Newest first, as the API returns them: 12 is the latest, 3 is the root
const docVersions: DocumentVersionInfo[] = [
{ id: 12, is_root: false },
{ id: 10, is_root: false },
{ id: doc.id, is_root: true },
]
const customFields = [
{
id: 0,
@@ -2045,6 +2051,208 @@ describe('DocumentDetailComponent', () => {
expect(saveSpy).toHaveBeenCalled()
})
it('selectVersion should use the version content as the baseline and ignore stale responses', () => {
initNormally()
const version10Content = new Subject<Document>()
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(version10Content)
.mockReturnValueOnce(of({ content: 'version 12 content' } as Document))
const version10Metadata = new Subject<any>()
jest
.spyOn(documentService, 'getMetadata')
.mockReturnValueOnce(version10Metadata)
.mockReturnValueOnce(of({ lang: 'de' }))
component.selectVersion(10)
component.selectVersion(12)
version10Content.next({ content: 'version 10 content' } as Document)
version10Metadata.next({ lang: 'en' })
expect(component.documentForm.get('content').value).toEqual(
'version 12 content'
)
expect(component.store.value.content).toEqual('version 12 content')
expect(component.metadata().lang).toEqual('de')
expect(
httpTestingController.expectOne(component.previewUrl()).cancelled
).toBeFalsy()
expect(
httpTestingController.match((req) => req.url.includes('version=10'))[0]
?.cancelled
).toBeTruthy()
})
it('should confirm before discarding unsaved content edits when switching versions', () => {
initNormally()
component.document().versions = docVersions
jest
.spyOn(documentService, 'get')
.mockImplementation((id, versionID) =>
of({ content: `version ${versionID} content` } as Document)
)
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
const modalSpy = jest.spyOn(modalService, 'open')
// shared fields carry over between versions, so no confirmation
component.documentForm.get('title').setValue('Edited title')
component.documentForm.get('title').markAsDirty()
component.documentForm.get('content').markAsDirty()
component.onVersionSelected(12)
expect(modalSpy).not.toHaveBeenCalled()
expect(component.selectedVersionId()).toEqual(12)
component.documentForm.get('content').setValue('edited content')
component.documentForm.get('content').markAsDirty()
component.onVersionSelected(12) // already selected, nothing to do
expect(modalSpy).not.toHaveBeenCalled()
component.onVersionSelected(10)
expect(modalSpy).toHaveBeenCalledWith(
ConfirmDialogComponent,
expect.anything()
)
openModal.componentInstance.cancel()
expect(component.selectedVersionId()).toEqual(12)
expect(component.documentForm.get('content').value).toEqual(
'edited content'
)
component.onVersionSelected(10)
openModal.componentInstance.confirmClicked.emit()
expect(component.selectedVersionId()).toEqual(10)
expect(component.documentForm.get('content').value).toEqual(
'version 10 content'
)
expect(component.documentForm.get('content').dirty).toBeFalsy()
expect(component.documentForm.get('title').value).toEqual('Edited title')
})
it('should save unsaved content edits to the current version before switching, and stay if that fails', () => {
initNormally()
component.document().versions = docVersions
component.selectedVersionId.set(12)
jest
.spyOn(documentService, 'get')
.mockReturnValue(of({ content: 'version 10 content' } as Document))
const savedDoc = new Subject<Document>()
const patchSpy = jest
.spyOn(documentService, 'patch')
.mockReturnValueOnce(throwError(() => new Error('failed to save')))
.mockReturnValueOnce(savedDoc)
const modalSpy = jest.spyOn(modalService, 'open')
component.documentForm.get('content').setValue('edited content')
component.documentForm.get('content').markAsDirty()
component.onVersionSelected(10)
let modal: NgbModalRef = modalSpy.mock.results[0].value
const closeSpy = jest.spyOn(modal, 'close')
modal.componentInstance.alternativeClicked.emit()
expect(closeSpy).toHaveBeenCalled()
expect(component.selectedVersionId()).toEqual(12)
expect(component.documentForm.get('content').value).toEqual(
'edited content'
)
component.onVersionSelected(10)
modal = modalSpy.mock.results[1].value
modal.componentInstance.alternativeClicked.emit()
expect(patchSpy).toHaveBeenLastCalledWith(
expect.objectContaining({ content: 'edited content' }),
12
)
component.onVersionSelected(doc.id) // ignored while saving
expect(modalSpy).toHaveBeenCalledTimes(2)
savedDoc.next(doc)
expect(component.selectedVersionId()).toEqual(10)
expect(component.documentForm.get('content').value).toEqual(
'version 10 content'
)
})
it('should switch without confirmation when the selected version was deleted, even while saving', () => {
initNormally()
component.document().versions = docVersions
component.selectedVersionId.set(10)
jest
.spyOn(documentService, 'get')
.mockReturnValue(of({ content: 'version 12 content' } as Document))
const modalSpy = jest.spyOn(modalService, 'open')
component.documentForm.get('content').setValue('edited content')
component.documentForm.get('content').markAsDirty()
component.networkActive.set(true)
// the version dropdown emits this after deleting the selected version
component.onVersionsUpdated(docVersions.filter((v) => v.id !== 10))
component.onVersionSelected(12)
expect(modalSpy).not.toHaveBeenCalled()
expect(component.selectedVersionId()).toEqual(12)
expect(component.documentForm.get('content').value).toEqual(
'version 12 content'
)
})
it('should restore the selected version and its unsaved content when returning to a document', () => {
initNormally()
const openDoc = component.document()
openDoc.versions = docVersions
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
jest
.spyOn(documentService, 'get')
.mockImplementation((id, versionID) =>
of(
(versionID
? { content: `version ${versionID} content` }
: { ...doc, versions: docVersions }) as Document
)
)
component.selectVersion(10)
// an edit that happens to match the latest version's content
component.documentForm.get('content').setValue(doc.content)
openDoc.__changedFields = ['content']
component['loadDocument'](doc.id)
expect(component.selectedVersionId()).toEqual(10)
expect(component.documentForm.get('content').value).toEqual(doc.content)
expect(openDocumentsService.isDirty(openDoc)).toBeTruthy()
const patchSpy = jest
.spyOn(documentService, 'patch')
.mockReturnValue(of(doc))
component.save()
expect(patchSpy).toHaveBeenCalledWith(
expect.objectContaining({ content: doc.content }),
10
)
})
it('should fall back to the latest version when the remembered version no longer exists', () => {
initNormally()
const openDoc = component.document()
openDoc.versions = docVersions
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
jest.spyOn(documentService, 'get').mockImplementation((id, versionID) =>
of(
(versionID
? { content: `version ${versionID} content` }
: {
...doc,
content: 'version 12 content',
versions: docVersions.filter((v) => v.id !== 10),
}) as Document
)
)
component.selectVersion(10)
component['loadDocument'](doc.id)
expect(component.selectedVersionId()).toEqual(12)
expect(component.documentForm.get('content').value).toEqual(
'version 12 content'
)
})
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
currentUserCan = false
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
@@ -98,8 +98,8 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif'
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
@@ -304,6 +304,7 @@ export class DocumentDetailComponent
isDirty$: Observable<boolean>
unsubscribeNotifier: Subject<any> = new Subject()
docChangeNotifier: Subject<any> = new Subject()
versionChangeNotifier: Subject<void> = new Subject()
private incomingUpdateModal: NgbModalRef
private pendingIncomingUpdate: IncomingDocumentUpdate
private lastLocalSaveModified: string | null = null
@@ -417,7 +418,8 @@ export class DocumentDetailComponent
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
takeUntil(this.docChangeNotifier),
takeUntil(this.versionChangeNotifier)
)
.subscribe({
next: (result) => {
@@ -533,7 +535,8 @@ export class DocumentDetailComponent
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
takeUntil(this.docChangeNotifier),
takeUntil(this.versionChangeNotifier)
)
.subscribe({
next: (res) => this.previewText.set(res.toString()),
@@ -595,6 +598,13 @@ export class DocumentDetailComponent
openDocument.duplicate_documents = doc.duplicate_documents
this.openDocumentService.save()
}
// use server versions
if (openDocument) {
openDocument.versions = doc.versions
if (!openDocument.__changedFields?.includes('content')) {
openDocument.content = doc.content
}
}
let useDoc = openDocument || doc
if (openDocument && forceRemote) {
Object.assign(openDocument, doc)
@@ -642,7 +652,14 @@ export class DocumentDetailComponent
this.documentForm.patchValue({ title: titleValue })
this.documentForm.get('title').markAsDirty()
})
const keepContentEdits =
useDoc.__selectedVersionId === this.selectedVersionId() &&
!!useDoc.__changedFields?.includes('content')
this.setupDirtyTracking(useDoc, doc)
// Maybe load the stored version
if (useDoc.__selectedVersionId) {
this.selectVersion(this.selectedVersionId(), keepContentEdits)
}
},
})
}
@@ -903,9 +920,11 @@ export class DocumentDetailComponent
updateComponent(doc: Document) {
this.document.set(doc)
// Default selected version is the newest version, which the API returns first
// Load the selected version, or default to API first (newest)
const versions = doc.versions ?? []
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
const selectedVersion =
versions.find((v) => v.id === doc.__selectedVersionId) ?? versions[0]
this.selectedVersionId.set(selectedVersion?.id ?? doc.id)
this.previewLoaded.set(false)
this.requiresPassword = false
this.updateFormForCustomFields()
@@ -940,8 +959,12 @@ export class DocumentDetailComponent
}
// Update file preview and download target to a specific version (by document id)
selectVersion(versionId: number) {
selectVersion(versionId: number, keepContentEdits: boolean = false) {
this.versionChangeNotifier.next()
this.selectedVersionId.set(versionId)
// remember so the version can be restored when returning to the document
this.document().__selectedVersionId = versionId
this.openDocumentService.save()
this.previewLoaded.set(false)
this.previewUrl.set(
this.documentsService.getPreviewUrl(
@@ -963,20 +986,20 @@ export class DocumentDetailComponent
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
takeUntil(this.docChangeNotifier),
takeUntil(this.versionChangeNotifier)
)
.subscribe({
next: (doc) => {
const content = doc?.content ?? ''
this.document().content = content
this.documentForm.patchValue(
{
content,
},
{
emitEvent: false,
}
)
if (keepContentEdits) {
this.store.next({ ...this.store.value, content })
} else {
// Update in-place and avoid the debounce wait
this.store.value.content = content
this.documentForm.patchValue({ content })
this.documentForm.get('content').markAsPristine()
}
},
error: (error) => {
this.toastService.showError(
@@ -991,7 +1014,8 @@ export class DocumentDetailComponent
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
takeUntil(this.docChangeNotifier),
takeUntil(this.versionChangeNotifier)
)
.subscribe({
next: (res) => this.previewText.set(res.toString()),
@@ -1005,7 +1029,39 @@ export class DocumentDetailComponent
}
onVersionSelected(versionId: number) {
this.selectVersion(versionId)
if (versionId === this.selectedVersionId()) return
// Bail if the selected version was just deleted.
const selectedVersionExists = this.document()?.versions?.some(
(v) => v.id === this.selectedVersionId()
)
if (this.networkActive() && selectedVersionExists) return
if (
!selectedVersionExists ||
this.documentForm.get('content').value === this.store.value.content
) {
this.selectVersion(versionId)
return
}
// Confirm any unsaved content changes
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Unsaved Changes`
modal.componentInstance.messageBold = $localize`You have unsaved changes to the content of this version.`
modal.componentInstance.message = $localize`Switching versions will discard them.`
modal.componentInstance.btnClass = 'btn-secondary'
modal.componentInstance.btnCaption = $localize`Discard and switch`
modal.componentInstance.alternativeBtnClass = 'btn-primary'
modal.componentInstance.alternativeBtnCaption = $localize`Save and switch`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.close()
this.selectVersion(versionId)
})
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
modal.close()
this.save(false, () => this.selectVersion(versionId))
})
}
onVersionsUpdated(versions: DocumentVersionInfo[]) {
@@ -1233,7 +1289,7 @@ export class DocumentDetailComponent
return changes
}
save(close: boolean = false) {
save(close: boolean = false, savedCallback: () => void = null) {
this.networkActive.set(true)
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService
@@ -1266,6 +1322,7 @@ export class DocumentDetailComponent
this.flushPendingIncomingUpdate()
}
this.savedViewService.maybeRefreshDocumentCounts()
savedCallback?.()
},
error: (error) => {
this.networkActive.set(false)
+1
View File
@@ -167,6 +167,7 @@ export interface Document extends ObjectWithPermissions {
// Frontend only
__changedFields?: string[]
__selectedVersionId?: number
}
export interface DocumentVersionInfo {
@@ -221,6 +221,25 @@ describe('OpenDocumentsService', () => {
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
})
it('should refresh documents in place and keep unsaved edits', () => {
const openDoc = { ...documents[0] }
subscriptions.push(openDocumentsService.openDocument(openDoc).subscribe())
openDoc.title = 'Unsaved title'
openDocumentsService.setDirty(openDoc, true, { title: openDoc.title })
openDocumentsService.refreshDocument(openDoc.id)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${openDoc.id}/?full_perms=true`
)
.flush({ ...documents[0], tags: [4] })
const refreshed = openDocumentsService.getOpenDocument(openDoc.id)
expect(refreshed).toBe(openDoc)
expect(refreshed.title).toEqual('Unsaved title')
expect(refreshed.tags).toEqual([4])
})
it('should handle error on refresh documents', () => {
subscriptions.push(
openDocumentsService.openDocument(documents[1]).subscribe()
@@ -50,7 +50,15 @@ export class OpenDocumentsService {
if (index > -1) {
this.documentService.get(id).subscribe({
next: (doc) => {
this.openDocuments[index] = doc
const openDoc = this.openDocuments.find((d) => d.id == id)
if (!openDoc) return
const unsavedEdits = Object.fromEntries(
(openDoc.__changedFields ?? []).map((field) => [
field,
openDoc[field],
])
)
Object.assign(openDoc, doc, unsavedEdits)
this.save()
},
error: () => {
+1 -1
View File
@@ -129,7 +129,7 @@ class DocumentMetadataOverrides:
)
overrides.custom_fields = {
custom_field.field.id: custom_field.value
for custom_field in doc.custom_fields.select_related("field").all()
for custom_field in doc.custom_fields.all()
}
groups_with_perms = get_groups_with_perms(
+1 -81
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import logging
import math
import re
from collections.abc import Iterable
from datetime import datetime
from datetime import timedelta
from decimal import Decimal
@@ -884,86 +883,8 @@ def validate_documentlink_targets(user, doc_ids):
)
# drf-writable-nested revalidates a document's custom_fields more than once
# per request: once as the ordinary nested list, then again per-item while
# matching existing vs. new CustomFieldInstance rows during save() -- and
# that second pass builds a brand new serializer (and field) instance per
# item (see its update_or_create_reverse_relations / _get_serializer_for_field),
# so a cache on the field instance alone only helps the first pass. It does,
# however, explicitly pass `context=self.context` to every one of those
# fresh serializers -- the *same* dict object the outer DocumentSerializer
# is using, not a copy. That context dict is already request-scoped (DRF
# builds it fresh per request via get_serializer_context()), so stashing the
# resolved CustomField objects there -- rather than in some new global/
# thread-local cache -- lets every later pass reuse them for free while
# staying entirely within DRF's existing, already-request-scoped machinery.
_CUSTOM_FIELD_CONTEXT_CACHE_KEY = "_custom_field_lookup_cache"
class _CachingCustomFieldPrimaryKeyField(serializers.PrimaryKeyRelatedField):
"""
Resolves CustomField ids via a cache on the serializer context, so
later, separately-instantiated fields for the same request (drf-writable-
nested rebuilds one per item during save) reuse what was already
resolved instead of re-querying.
"""
def _cache(self) -> dict[int, CustomField]:
return self.context.setdefault(_CUSTOM_FIELD_CONTEXT_CACHE_KEY, {})
@staticmethod
def _normalize_pk(data: Any) -> int | None:
"""
Returns `data` coerced to the int a valid CustomField pk would be,
or None if `data` isn't a plausible pk (wrong type, unhashable,
non-numeric, or a bool -- DRF itself rejects bools as pks since
`True == 1` would otherwise silently match). None tells callers to
leave `data` alone and let `super().to_internal_value()` report the
normal validation error instead of touching the cache/queryset with
it directly.
"""
if isinstance(data, bool):
return None
try:
return int(data)
except (TypeError, ValueError):
return None
def prefetch(self, ids: Iterable[Any]) -> None:
cache = self._cache()
candidates = {pk for i in ids if (pk := self._normalize_pk(i)) is not None}
missing = {i for i in candidates if i not in cache}
if missing:
for obj in self.get_queryset().filter(pk__in=missing):
cache[obj.pk] = obj
def to_internal_value(self, data: Any) -> CustomField:
pk = self._normalize_pk(data)
if pk is None:
return super().to_internal_value(data)
cache = self._cache()
if pk in cache:
return cache[pk]
obj: CustomField = super().to_internal_value(data)
cache[obj.pk] = obj
return obj
class CustomFieldInstanceListSerializer(serializers.ListSerializer):
def to_internal_value(self, data: Any) -> list[Any]:
if isinstance(data, list):
field_ids = [
item["field"]
for item in data
if isinstance(item, dict) and "field" in item
]
if field_ids:
self.child.fields["field"].prefetch(field_ids)
return super().to_internal_value(data)
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
field = _CachingCustomFieldPrimaryKeyField(queryset=CustomField.objects.all())
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
value = ReadWriteSerializerMethodField(allow_null=True)
def create(self, validated_data):
@@ -1064,7 +985,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
class Meta:
model = CustomFieldInstance
list_serializer_class = CustomFieldInstanceListSerializer
fields = [
"value",
"field",
+36
View File
@@ -38,6 +38,42 @@ class TestChatStreamingViewInputValidation(APITestCase):
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
def test_answer_is_not_compressed(self) -> None:
"""
GIVEN:
- A client that accepts compressed responses
WHEN:
- It asks the chat endpoint a question
THEN:
- The answer is streamed unencoded, chunk for chunk
The stream compressors buffer, so a compressed answer arrives in one
piece. The view cannot opt out by flagging the request: DRF's request
wrapper proxies reads but keeps writes to itself, so the flag never
reaches the Django request the middleware sees.
"""
chunks = [f"token{i} " for i in range(40)]
with (
mock.patch(
"documents.views.AIConfig",
return_value=self._mock_ai_enabled(),
),
mock.patch(
"documents.views.stream_chat_with_documents",
return_value=iter(chunks),
),
):
resp = self.client.post(
"/api/documents/chat/",
{"q": "What is in my archive?"},
format="json",
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
)
assert resp.status_code == status.HTTP_200_OK
assert not resp.has_header("Content-Encoding")
assert list(resp.streaming_content) == [c.encode() for c in chunks]
def test_missing_question_is_rejected(self) -> None:
with mock.patch(
"documents.views.AIConfig",
@@ -5,19 +5,14 @@ from unittest.mock import ANY
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.db import connection
from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from guardian.shortcuts import assign_perm
from rest_framework import status
from rest_framework.test import APITestCase
from documents.data_models import DocumentMetadataOverrides
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.serialisers import DocumentSerializer
from documents.tests.factories import DocumentFactory
from documents.tests.utils import DirectoriesMixin
@@ -535,190 +530,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
doc.refresh_from_db()
self.assertEqual(len(doc.custom_fields.all()), 10)
def test_document_serializer_custom_fields_validation_batches_field_lookup(
self,
) -> None:
"""
GIVEN:
- A document is being validated with several custom field values
at once (as happens on every PATCH/PUT/POST)
WHEN:
- The serializer is validated
THEN:
- The referenced CustomField objects are resolved with a single
query, not one query per custom field
"""
doc = DocumentFactory(mime_type="application/pdf")
custom_fields = [
CustomField.objects.create(
name=f"Test Custom Field {i}",
data_type=CustomField.FieldDataType.STRING,
)
for i in range(5)
]
serializer = DocumentSerializer(
doc,
data={
"custom_fields": [
{"field": custom_field.id, "value": "test value"}
for custom_field in custom_fields
],
},
partial=True,
)
with CaptureQueriesContext(connection) as ctx:
self.assertTrue(serializer.is_valid(), serializer.errors)
custom_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
len(custom_field_lookups),
1,
"Expected a single batched query to resolve the custom fields, "
f"got {len(custom_field_lookups)}: {custom_field_lookups}",
)
def test_document_serializer_save_reuses_cached_custom_fields(self) -> None:
"""
GIVEN:
- A document is being saved with several custom field values via
DocumentSerializer, which drives drf-writable-nested's real
update_or_create_reverse_relations path -- rebuilding a fresh
CustomFieldInstanceSerializer per item during save(), the exact
mechanism the shared-context cache exists to optimize
WHEN:
- The serializer, already validated, is saved
THEN:
- No further CustomField queries are issued: each per-item
nested serializer reuses the CustomField objects resolved
during is_valid(), instead of re-resolving them during save()
"""
doc = DocumentFactory(mime_type="application/pdf")
custom_fields = [
CustomField.objects.create(
name=f"Test Custom Field {i}",
data_type=CustomField.FieldDataType.STRING,
)
for i in range(5)
]
serializer = DocumentSerializer(
doc,
data={
"custom_fields": [
{"field": custom_field.id, "value": "test value"}
for custom_field in custom_fields
],
},
partial=True,
)
self.assertTrue(serializer.is_valid(), serializer.errors)
with CaptureQueriesContext(connection) as ctx:
serializer.save()
custom_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
custom_field_lookups,
[],
"Expected save() to reuse CustomField objects resolved during "
f"is_valid(), got: {custom_field_lookups}",
)
self.assertEqual(doc.custom_fields.count(), 5)
def test_custom_field_validation_rejects_malformed_field_value(self) -> None:
"""
GIVEN:
- A document is being validated with a malformed custom_fields
entry whose "field" value is neither a valid CustomField id
nor a type DRF's own PrimaryKeyRelatedField can safely reject
on its own (unhashable, or a non-numeric scalar)
WHEN:
- The serializer is validated
THEN:
- A normal validation error is raised, not an unhandled
TypeError/ValueError escaping past DRF's validation layer
"""
doc = DocumentFactory(mime_type="application/pdf")
bad_field_values = {
"unhashable-list": [],
"unhashable-dict": {},
"non-numeric-scalar": "abc",
}
for case_id, bad_field_value in bad_field_values.items():
with self.subTest(case_id):
serializer = DocumentSerializer(
doc,
data={
"custom_fields": [
{"field": bad_field_value, "value": "test value"},
],
},
partial=True,
)
self.assertFalse(serializer.is_valid())
self.assertIn("custom_fields", serializer.errors)
def test_document_metadata_overrides_from_document_batches_field_lookup(
self,
) -> None:
"""
GIVEN:
- A document has several custom field values
- The document instance has just been refreshed from the database,
which drops any prefetched related objects (as
send_websocket_document_updated does before building overrides)
WHEN:
- DocumentMetadataOverrides.from_document() reads the document's
custom field values
THEN:
- The referenced CustomField objects are resolved with a single
query, not one query per custom field
"""
doc = DocumentFactory(mime_type="application/pdf")
for i in range(5):
CustomFieldInstance.objects.create(
document=doc,
field=CustomField.objects.create(
name=f"Test Custom Field {i}",
data_type=CustomField.FieldDataType.STRING,
),
value_text="value",
)
doc.refresh_from_db()
with CaptureQueriesContext(connection) as ctx:
overrides = DocumentMetadataOverrides.from_document(doc)
self.assertEqual(len(overrides.custom_fields), 5)
unbatched_field_lookups = [
query
for query in ctx.captured_queries
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
in query["sql"]
]
self.assertEqual(
unbatched_field_lookups,
[],
"Expected CustomField data to come from the CustomFieldInstance "
"join, not a separate per-instance lookup, "
f"got: {unbatched_field_lookups}",
)
def test_change_custom_field_instance_value(self) -> None:
"""
GIVEN:
+7
View File
@@ -1,5 +1,6 @@
import pytest
import regex
from django.conf import settings
from pytest_mock import MockerFixture
from documents.regex import safe_regex_finditer
@@ -9,6 +10,12 @@ from documents.regex import safe_regex_sub
from documents.regex import validate_regex_pattern
def test_regex_timeout_uses_configured_setting() -> None:
from documents.regex import REGEX_TIMEOUT_SECONDS
assert REGEX_TIMEOUT_SECONDS == settings.MATCH_REGEX_TIMEOUT_SECONDS
class TestValidateRegexPattern:
def test_valid_pattern(self) -> None:
validate_regex_pattern(r"\d+")
-1
View File
@@ -2380,7 +2380,6 @@ class ChatStreamingView(GenericAPIView[Any]):
serializer_class = ChatStreamingSerializer
def post(self, request, *args, **kwargs):
request.compress_exempt = True
ai_config = AIConfig()
if not ai_config.ai_enabled:
return HttpResponseBadRequest("AI is required for this feature")
+46 -46
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-10 19:39+0000\n"
"POT-Creation-Date: 2026-09-11 15:47+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1632,7 +1632,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:525 documents/serialisers.py:882
#: documents/serialisers.py:2849 documents/views.py:318 documents/views.py:2694
#: documents/serialisers.py:2849 documents/views.py:318 documents/views.py:2693
#: paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
@@ -1673,7 +1673,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2935 documents/views.py:4701
#: documents/serialisers.py:2935 documents/views.py:4700
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1941,7 +1941,7 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:311 documents/views.py:2691
#: documents/views.py:311 documents/views.py:2690
msgid "Invalid more_like_id"
msgstr ""
@@ -1957,24 +1957,24 @@ msgstr ""
msgid "AI backend rejected the request. Check logs for details."
msgstr ""
#: documents/views.py:2516 documents/views.py:2837
#: documents/views.py:2515 documents/views.py:2836
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4714
#: documents/views.py:4713
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4760
#: documents/views.py:4759
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4824
#: documents/views.py:4823
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4838
#: documents/views.py:4837
msgid "The share link bundle is unavailable."
msgstr ""
@@ -2258,151 +2258,151 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings/__init__.py:569
#: paperless/settings/__init__.py:556
msgid "English (US)"
msgstr ""
#: paperless/settings/__init__.py:570
#: paperless/settings/__init__.py:557
msgid "Arabic"
msgstr ""
#: paperless/settings/__init__.py:571
#: paperless/settings/__init__.py:558
msgid "Afrikaans"
msgstr ""
#: paperless/settings/__init__.py:572
#: paperless/settings/__init__.py:559
msgid "Belarusian"
msgstr ""
#: paperless/settings/__init__.py:573
#: paperless/settings/__init__.py:560
msgid "Bulgarian"
msgstr ""
#: paperless/settings/__init__.py:574
#: paperless/settings/__init__.py:561
msgid "Catalan"
msgstr ""
#: paperless/settings/__init__.py:575
#: paperless/settings/__init__.py:562
msgid "Czech"
msgstr ""
#: paperless/settings/__init__.py:576
#: paperless/settings/__init__.py:563
msgid "Danish"
msgstr ""
#: paperless/settings/__init__.py:577
#: paperless/settings/__init__.py:564
msgid "German"
msgstr ""
#: paperless/settings/__init__.py:578
#: paperless/settings/__init__.py:565
msgid "Greek"
msgstr ""
#: paperless/settings/__init__.py:579
#: paperless/settings/__init__.py:566
msgid "English (GB)"
msgstr ""
#: paperless/settings/__init__.py:580
#: paperless/settings/__init__.py:567
msgid "Spanish"
msgstr ""
#: paperless/settings/__init__.py:581
#: paperless/settings/__init__.py:568
msgid "Persian"
msgstr ""
#: paperless/settings/__init__.py:582
#: paperless/settings/__init__.py:569
msgid "Finnish"
msgstr ""
#: paperless/settings/__init__.py:583
#: paperless/settings/__init__.py:570
msgid "French"
msgstr ""
#: paperless/settings/__init__.py:584
#: paperless/settings/__init__.py:571
msgid "Hungarian"
msgstr ""
#: paperless/settings/__init__.py:585
#: paperless/settings/__init__.py:572
msgid "Indonesian"
msgstr ""
#: paperless/settings/__init__.py:586
#: paperless/settings/__init__.py:573
msgid "Italian"
msgstr ""
#: paperless/settings/__init__.py:587
#: paperless/settings/__init__.py:574
msgid "Japanese"
msgstr ""
#: paperless/settings/__init__.py:588
#: paperless/settings/__init__.py:575
msgid "Korean"
msgstr ""
#: paperless/settings/__init__.py:589
#: paperless/settings/__init__.py:576
msgid "Luxembourgish"
msgstr ""
#: paperless/settings/__init__.py:590
#: paperless/settings/__init__.py:577
msgid "Norwegian"
msgstr ""
#: paperless/settings/__init__.py:591
#: paperless/settings/__init__.py:578
msgid "Dutch"
msgstr ""
#: paperless/settings/__init__.py:592
#: paperless/settings/__init__.py:579
msgid "Polish"
msgstr ""
#: paperless/settings/__init__.py:593
#: paperless/settings/__init__.py:580
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings/__init__.py:594
#: paperless/settings/__init__.py:581
msgid "Portuguese"
msgstr ""
#: paperless/settings/__init__.py:595
#: paperless/settings/__init__.py:582
msgid "Romanian"
msgstr ""
#: paperless/settings/__init__.py:596
#: paperless/settings/__init__.py:583
msgid "Russian"
msgstr ""
#: paperless/settings/__init__.py:597
#: paperless/settings/__init__.py:584
msgid "Slovak"
msgstr ""
#: paperless/settings/__init__.py:598
#: paperless/settings/__init__.py:585
msgid "Slovenian"
msgstr ""
#: paperless/settings/__init__.py:599
#: paperless/settings/__init__.py:586
msgid "Serbian"
msgstr ""
#: paperless/settings/__init__.py:600
#: paperless/settings/__init__.py:587
msgid "Swedish"
msgstr ""
#: paperless/settings/__init__.py:601
#: paperless/settings/__init__.py:588
msgid "Turkish"
msgstr ""
#: paperless/settings/__init__.py:602
#: paperless/settings/__init__.py:589
msgid "Ukrainian"
msgstr ""
#: paperless/settings/__init__.py:603
#: paperless/settings/__init__.py:590
msgid "Vietnamese"
msgstr ""
#: paperless/settings/__init__.py:604
#: paperless/settings/__init__.py:591
msgid "Chinese Simplified"
msgstr ""
#: paperless/settings/__init__.py:605
#: paperless/settings/__init__.py:592
msgid "Chinese Traditional"
msgstr ""
+15
View File
@@ -1,8 +1,23 @@
from compression_middleware.middleware import CompressionMiddleware
from django.conf import settings
from paperless import version
class StreamAwareCompressionMiddleware(CompressionMiddleware):
"""
Bypasses compression for server-sent streams (text/event-stream).
See https://github.com/friedelwolff/django-compression-middleware/pull/7
"""
def process_response(self, request, response):
content_type = response.headers.get("Content-Type", "")
if content_type.startswith("text/event-stream"):
return response
return super().process_response(request, response)
class ApiVersionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
+7 -16
View File
@@ -10,7 +10,6 @@ from pathlib import Path
from typing import Final
from urllib.parse import urlparse
from compression_middleware.middleware import CompressionMiddleware
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv
@@ -103,6 +102,10 @@ CLASSIFIER_MATCH_THRESHOLD: Final[float] = get_float_from_env(
"PAPERLESS_CLASSIFIER_MATCH_THRESHOLD",
0.6,
)
MATCH_REGEX_TIMEOUT_SECONDS: Final[float] = get_float_from_env(
"PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS",
0.1,
)
LLM_INDEX_DIR = DATA_DIR / "llm_index"
LLM_INDEX_LOCK = LLM_INDEX_DIR / "index.lock"
# Cross-process read/write lock guarding the LLM index compaction/migration
@@ -201,22 +204,10 @@ MIDDLEWARE = [
"allauth.account.middleware.AccountMiddleware",
]
# Optional to enable compression
# Optional to enable compression. The subclass leaves server-sent events
# uncompressed; see paperless.middleware.StreamAwareCompressionMiddleware.
if get_bool_from_env("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover
MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware")
# Workaround to not compress streaming responses (e.g. chat).
# See https://github.com/friedelwolff/django-compression-middleware/pull/7
original_process_response = CompressionMiddleware.process_response
def patched_process_response(self, request, response):
if getattr(request, "compress_exempt", False):
return response
return original_process_response(self, request, response)
CompressionMiddleware.process_response = patched_process_response
MIDDLEWARE.insert(0, "paperless.middleware.StreamAwareCompressionMiddleware")
ROOT_URLCONF = "paperless.urls"
@@ -0,0 +1,54 @@
from django.http import HttpResponse
from django.http import StreamingHttpResponse
from django.test import RequestFactory
from django.test import TestCase
from paperless.middleware import StreamAwareCompressionMiddleware
class TestStreamAwareCompressionMiddleware(TestCase):
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.middleware = StreamAwareCompressionMiddleware(lambda request: None)
def _request(self):
return self.factory.get(
"/api/documents/chat/",
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
)
def test_event_stream_is_not_compressed(self) -> None:
"""
GIVEN:
- A server-sent event response produced chunk by chunk
WHEN:
- The compression middleware processes it
THEN:
- It is passed through unencoded, one wire chunk per source chunk
"""
chunks = [f"token{i} ".encode() for i in range(40)]
response = StreamingHttpResponse(
iter(chunks),
content_type="text/event-stream",
)
response = self.middleware.process_response(self._request(), response)
assert not response.has_header("Content-Encoding")
assert list(response.streaming_content) == chunks
def test_regular_response_is_still_compressed(self) -> None:
"""
GIVEN:
- An ordinary response large enough to be worth compressing
WHEN:
- The compression middleware processes it
THEN:
- It is compressed as before
"""
response = HttpResponse(b"a" * 5000, content_type="application/json")
response = self.middleware.process_response(self._request(), response)
assert response.has_header("Content-Encoding")
-1
View File
@@ -40,7 +40,6 @@ LLM_SYSTEM_PROMPT = (
# openai-python rejects empty keys since 2.34.0, "fake" is the stand-in from
# llama-index's own OpenAILike docs https://docs.llamaindex.ai/en/stable/api_reference/llms/openai_like/
# TODO: remove pending resolution of https://github.com/openai/openai-python/issues/3224
PLACEHOLDER_API_KEY: Final = "fake"