Compare commits

..
30 changed files with 444 additions and 529 deletions
+1 -1
View File
@@ -1215,7 +1215,7 @@ should be a valid crontab(5) expression describing when to run.
: If set to the string "disable", no emails will be fetched automatically. : If set to the string "disable", no emails will be fetched automatically.
Defaults to `*/10 * * * *` or every ten minutes. Defaults to every ten minutes, with an installation-specific minute offset.
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON} #### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
+1 -2
View File
@@ -1090,8 +1090,7 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence). [Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
settings can be supplied as environment variables or via **Application Configuration**.
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
or page limitations (e.g. with a free tier). or page limitations (e.g. with a free tier).
+26 -26
View File
@@ -2195,7 +2195,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">64,65</context> <context context-type="linenumber">67,68</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2134950584701094962" datatype="html"> <trans-unit id="2134950584701094962" datatype="html">
@@ -6180,7 +6180,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">35,36</context> <context context-type="linenumber">38,39</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5554528553553249088" datatype="html"> <trans-unit id="5554528553553249088" datatype="html">
@@ -8361,11 +8361,11 @@
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source> <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2023</context> <context context-type="linenumber">2025</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">2029</context> <context context-type="linenumber">2031</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">
@@ -8407,148 +8407,148 @@
<source>Label</source> <source>Label</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">10,11</context> <context context-type="linenumber">13,14</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3915966149686975421" datatype="html"> <trans-unit id="3915966149686975421" datatype="html">
<source>Optional</source> <source>Optional</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">17,18</context> <context context-type="linenumber">20,21</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3009569956897708604" datatype="html"> <trans-unit id="3009569956897708604" datatype="html">
<source>Upload a new version</source> <source>Upload a new version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">32,33</context> <context context-type="linenumber">35,36</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="278763911843514709" datatype="html"> <trans-unit id="278763911843514709" datatype="html">
<source>Use an existing document</source> <source>Use an existing document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">41,42</context> <context context-type="linenumber">44,45</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1906507263389090131" datatype="html"> <trans-unit id="1906507263389090131" datatype="html">
<source>Existing</source> <source>Existing</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">44,45</context> <context context-type="linenumber">47,48</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3119565892291077820" datatype="html"> <trans-unit id="3119565892291077820" datatype="html">
<source>Uploading version...</source> <source>Uploading version...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">52,53</context> <context context-type="linenumber">55,56</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8538204518127762311" datatype="html"> <trans-unit id="8538204518127762311" datatype="html">
<source>Processing version...</source> <source>Processing version...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">58,59</context> <context context-type="linenumber">61,62</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6587183611950398104" datatype="html"> <trans-unit id="6587183611950398104" datatype="html">
<source>Version upload failed.</source> <source>Version upload failed.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">63,64</context> <context context-type="linenumber">66,67</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6328986243040954523" datatype="html"> <trans-unit id="6328986243040954523" datatype="html">
<source>Version label</source> <source>Version label</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">88,89</context> <context context-type="linenumber">91,92</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">98,99</context> <context context-type="linenumber">101,102</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2724055831234181057" datatype="html"> <trans-unit id="2724055831234181057" datatype="html">
<source>Version</source> <source>Version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">109</context> <context context-type="linenumber">112</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2491751767883178298" datatype="html"> <trans-unit id="2491751767883178298" datatype="html">
<source>Delete this version?</source> <source>Delete this version?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">140,141</context> <context context-type="linenumber">143,144</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="894108684749537200" datatype="html"> <trans-unit id="894108684749537200" datatype="html">
<source>Delete version</source> <source>Delete version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
<context context-type="linenumber">145,146</context> <context context-type="linenumber">149,150</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9111284310237278846" datatype="html"> <trans-unit id="9111284310237278846" datatype="html">
<source>Error deleting version</source> <source>Error deleting version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">158</context> <context context-type="linenumber">164</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1450293275912542759" datatype="html"> <trans-unit id="1450293275912542759" datatype="html">
<source>Error updating version label</source> <source>Error updating version label</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">191</context> <context context-type="linenumber">197</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3567158019134759286" datatype="html"> <trans-unit id="3567158019134759286" datatype="html">
<source>Uploading new version. Processing will happen in the background.</source> <source>Uploading new version. Processing will happen in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">213</context> <context context-type="linenumber">219</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1549684689040001385" datatype="html"> <trans-unit id="1549684689040001385" datatype="html">
<source>Missing task ID.</source> <source>Missing task ID.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">226</context> <context context-type="linenumber">232</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="205445381296663832" datatype="html"> <trans-unit id="205445381296663832" datatype="html">
<source>Upload failed.</source> <source>Upload failed.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">248</context> <context context-type="linenumber">254</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">272</context> <context context-type="linenumber">278</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8350861980330256599" datatype="html"> <trans-unit id="8350861980330256599" datatype="html">
<source>Error uploading new version</source> <source>Error uploading new version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">275</context> <context context-type="linenumber">281</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4050545381573552340" datatype="html"> <trans-unit id="4050545381573552340" datatype="html">
<source>Existing document added as a version.</source> <source>Existing document added as a version.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">318</context> <context context-type="linenumber">324</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8552490241798412439" datatype="html"> <trans-unit id="8552490241798412439" datatype="html">
<source>Error adding existing document as a version</source> <source>Error adding existing document as a version</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
<context context-type="linenumber">323</context> <context context-type="linenumber">329</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6299008920007331381" datatype="html"> <trans-unit id="6299008920007331381" datatype="html">
@@ -14,48 +14,43 @@
<a ngbNavLink>{{category}}</a> <a ngbNavLink>{{category}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="p-3"> <div class="p-3">
@for (section of getCategorySections(category); track section) { <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@if (section) { @for (option of getCategoryOptions(category); track option.key) {
<h5 class="mt-4 mb-3">{{section}}</h5> <div class="col">
} <div class="card bg-light">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2"> <div class="card-body">
@for (option of getCategoryOptions(category, section); track option.key) { <div class="card-title d-flex align-items-center">
<div class="col"> <h6 class="mb-0">
<div class="card bg-light"> {{option.title}}
<div class="card-body"> </h6>
<div class="card-title d-flex align-items-center"> <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">
<h6 class="mb-0"> <i-bs name="info-circle"></i-bs>
{{option.title}} </a>
</h6> @if (isSet(option.key)) {
<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"> <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 name="info-circle"></i-bs> <i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</a> </button>
@if (isSet(option.key)) {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
</div>
<div class="mb-n3">
@switch (option.type) {
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
}
</div>
@if (option.note) {
<div class="form-text fst-italic">{{option.note}}</div>
} }
</div> </div>
<div class="mb-n3">
@switch (option.type) {
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
}
</div>
@if (option.note) {
<div class="form-text fst-italic">{{option.note}}</div>
}
</div> </div>
</div> </div>
} </div>
</div> }
} </div>
</div> </div>
</ng-template> </ng-template>
</li> </li>
@@ -8,11 +8,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
import { import { OutputTypeConfig } from 'src/app/data/paperless-config'
ConfigCategory,
ConfigSection,
OutputTypeConfig,
} from 'src/app/data/paperless-config'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -162,23 +158,4 @@ describe('ConfigComponent', () => {
component.resetOption('barcodes_enabled') component.resetOption('barcodes_enabled')
expect(component.configForm.get('barcodes_enabled').value).toBeNull() expect(component.configForm.get('barcodes_enabled').value).toBeNull()
}) })
it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
expect(
component
.getCategoryOptions(ConfigCategory.OCR)
.map((option) => option.key)
).toContain('output_type')
expect(
component
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
.map((option) => option.key)
).toEqual([
'remote_ocr_engine',
'remote_ocr_api_key',
'remote_ocr_endpoint',
])
})
}) })
@@ -74,20 +74,8 @@ export class ConfigComponent
return Object.values(ConfigCategory) return Object.values(ConfigCategory)
} }
getCategorySections(category: string): string[] { getCategoryOptions(category: string): ConfigOption[] {
return [ return PaperlessConfigOptions.filter((o) => o.category === category)
...new Set(
PaperlessConfigOptions.filter((o) => o.category === category).map(
(o) => o.section ?? null // null means no section
)
),
]
}
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
return PaperlessConfigOptions.filter(
(o) => o.category === category && (o.section ?? null) === section
)
} }
initialConfig: PaperlessConfig initialConfig: PaperlessConfig
@@ -2171,6 +2171,11 @@ describe('DocumentDetailComponent', () => {
).toBe(10) ).toBe(10)
component.openEmailDocument() component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(
(
modalSpy.mock.results[1].value as NgbModalRef
).componentInstance.documentIds()
).toEqual([10])
}) })
it('should set previewText', () => { it('should set previewText', () => {
@@ -1973,7 +1973,9 @@ export class DocumentDetailComponent
const modal = this.modalService.open(EmailDocumentDialogComponent, { const modal = this.modalService.open(EmailDocumentDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.documentIds.set([this.document().id]) modal.componentInstance.documentIds.set([
this.selectedVersionId() ?? this.document().id,
])
modal.componentInstance.hasArchiveVersion.set( modal.componentInstance.hasArchiveVersion.set(
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name !!this.document()?.archived_file_name
@@ -2,6 +2,9 @@
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle> <button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle>
<i-bs name="file-earmark-diff"></i-bs> <i-bs name="file-earmark-diff"></i-bs>
<span class="d-none d-lg-inline ps-1" i18n>Versions</span> <span class="d-none d-lg-inline ps-1" i18n>Versions</span>
@if (versions.length > 1) {
<span class="badge text-bg-secondary ms-1">{{ versions.length }}</span>
}
</button> </button>
<div class="dropdown-menu shadow" ngbDropdownMenu> <div class="dropdown-menu shadow" ngbDropdownMenu>
<div class="px-3 py-2 mb-2"> <div class="px-3 py-2 mb-2">
@@ -141,6 +144,7 @@
i18n-confirmMessage i18n-confirmMessage
[disabled]="!userIsOwner || !userCanEdit" [disabled]="!userIsOwner || !userCanEdit"
(confirm)="deleteVersion(version.id)" (confirm)="deleteVersion(version.id)"
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
> >
<span class="visually-hidden" i18n>Delete version</span> <span class="visually-hidden" i18n>Delete version</span>
</pngx-confirm-button> </pngx-confirm-button>
@@ -1,10 +1,16 @@
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { SimpleChange, signal } from '@angular/core' import { SimpleChange, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs' import { Subject, of, throwError } from 'rxjs'
import { DocumentVersionInfo } from 'src/app/data/document' import { DocumentVersionInfo } from 'src/app/data/document'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -31,6 +37,9 @@ describe('DocumentVersionDropdownComponent', () => {
let finished$: Subject<{ taskId: string }> let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }> let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>> let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
let permissionsService: jest.Mocked<
Pick<PermissionsService, 'currentUserCan'>
>
beforeEach(async () => { beforeEach(async () => {
finished$ = new Subject<{ taskId: string }>() finished$ = new Subject<{ taskId: string }>()
@@ -47,6 +56,9 @@ describe('DocumentVersionDropdownComponent', () => {
showError: jest.fn(), showError: jest.fn(),
showInfo: jest.fn(), showInfo: jest.fn(),
} }
permissionsService = {
currentUserCan: jest.fn().mockReturnValue(true),
}
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ imports: [
@@ -73,6 +85,10 @@ describe('DocumentVersionDropdownComponent', () => {
provide: NgbModal, provide: NgbModal,
useValue: modalService, useValue: modalService,
}, },
{
provide: PermissionsService,
useValue: permissionsService,
},
{ {
provide: WebsocketStatusService, provide: WebsocketStatusService,
useValue: { useValue: {
@@ -143,6 +159,31 @@ describe('DocumentVersionDropdownComponent', () => {
) )
}) })
it('should not show version delete buttons without document delete permission', () => {
fixture.destroy()
permissionsService.currentUserCan.mockReturnValue(false)
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
component = fixture.componentInstance
component.documentId = 3
component.selectedVersionId = 3
component.userIsOwner = true
component.userCanEdit = true
component.versions = [
{ id: 3, is_root: true, checksum: 'aaaa' },
{ id: 10, is_root: false, checksum: 'bbbb' },
]
fixture.detectChanges()
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
PermissionAction.Delete,
PermissionType.Document
)
expect(
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
).toHaveLength(0)
})
it('beginEditingVersion should set active row and draft label', () => { it('beginEditingVersion should set active row and draft label', () => {
component.userCanEdit = true component.userCanEdit = true
component.userIsOwner = true component.userIsOwner = true
@@ -25,6 +25,7 @@ import {
tap, tap,
} from 'rxjs/operators' } from 'rxjs/operators'
import { DocumentVersionInfo } from 'src/app/data/document' import { DocumentVersionInfo } from 'src/app/data/document'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -33,6 +34,7 @@ import {
WebsocketStatusService, WebsocketStatusService,
} from 'src/app/services/websocket-status.service' } from 'src/app/services/websocket-status.service'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component' import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
@Component({ @Component({
@@ -44,11 +46,15 @@ import { AddExistingDocumentVersionDialogComponent } from './add-existing-docume
NgbDropdownModule, NgbDropdownModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ConfirmButtonComponent, ConfirmButtonComponent,
IfPermissionsDirective,
SlicePipe, SlicePipe,
CustomDatePipe, CustomDatePipe,
], ],
}) })
export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy { export class DocumentVersionDropdownComponent
extends ComponentWithPermissions
implements OnChanges, OnDestroy
{
UploadState = UploadState UploadState = UploadState
@Input() documentId: number @Input() documentId: number
-39
View File
@@ -54,10 +54,6 @@ export const ConfigCategory = {
AI: $localize`AI Settings`, AI: $localize`AI Settings`,
} }
export const ConfigSection = {
RemoteOCR: $localize`Remote OCR`,
}
export const LLMEmbeddingBackendConfig = { export const LLMEmbeddingBackendConfig = {
OPENAI_LIKE: 'openai-like', OPENAI_LIKE: 'openai-like',
HUGGINGFACE: 'huggingface', HUGGINGFACE: 'huggingface',
@@ -69,10 +65,6 @@ export const LLMBackendConfig = {
OLLAMA: 'ollama', OLLAMA: 'ollama',
} }
export const RemoteOCREngineConfig = {
AZURE_AI: 'azureai',
}
export interface ConfigOption { export interface ConfigOption {
key: string key: string
title: string title: string
@@ -80,7 +72,6 @@ export interface ConfigOption {
choices?: Array<{ id: string; name: string }> choices?: Array<{ id: string; name: string }>
config_key?: string config_key?: string
category: string category: string
section?: string
note?: string note?: string
} }
@@ -190,33 +181,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_OCR_USER_ARGS', config_key: 'PAPERLESS_OCR_USER_ARGS',
category: ConfigCategory.OCR, category: ConfigCategory.OCR,
}, },
{
key: 'remote_ocr_engine',
title: $localize`Remote OCR Engine`,
type: ConfigOptionType.Select,
choices: mapToItems(RemoteOCREngineConfig),
config_key: 'PAPERLESS_REMOTE_OCR_ENGINE',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`,
},
{
key: 'remote_ocr_api_key',
title: $localize`Remote OCR API Key`,
type: ConfigOptionType.Password,
config_key: 'PAPERLESS_REMOTE_OCR_API_KEY',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
},
{
key: 'remote_ocr_endpoint',
title: $localize`Remote OCR Endpoint`,
type: ConfigOptionType.String,
config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT',
category: ConfigCategory.OCR,
section: ConfigSection.RemoteOCR,
note: $localize`Required when using the Azure AI engine.`,
},
{ {
key: 'app_logo', key: 'app_logo',
title: $localize`Application Logo`, title: $localize`Application Logo`,
@@ -434,9 +398,6 @@ export interface PaperlessConfig extends ObjectWithId {
barcode_enable_tag: boolean barcode_enable_tag: boolean
barcode_tag_mapping: object barcode_tag_mapping: object
barcode_tag_split: boolean barcode_tag_split: boolean
remote_ocr_engine: string
remote_ocr_api_key: string
remote_ocr_endpoint: string
ai_enabled: boolean ai_enabled: boolean
llm_embedding_backend: string llm_embedding_backend: string
llm_embedding_model: string llm_embedding_model: string
@@ -72,9 +72,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"barcode_enable_tag": None, "barcode_enable_tag": None,
"barcode_tag_mapping": None, "barcode_tag_mapping": None,
"barcode_tag_split": None, "barcode_tag_split": None,
"remote_ocr_engine": None,
"remote_ocr_api_key": None,
"remote_ocr_endpoint": None,
"ai_enabled": False, "ai_enabled": False,
"llm_embedding_backend": None, "llm_embedding_backend": None,
"llm_embedding_model": None, "llm_embedding_model": None,
@@ -873,49 +870,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
config.refresh_from_db() config.refresh_from_db()
self.assertEqual(config.llm_api_key, None) self.assertEqual(config.llm_api_key, None)
def test_update_remote_ocr_api_key(self) -> None:
"""
GIVEN:
- Existing config with remote_ocr_api_key specified
WHEN:
- API to update remote_ocr_api_key is called with all *s
- API to update remote_ocr_api_key is called with empty string
THEN:
- remote_ocr_api_key is unchanged
- remote_ocr_api_key is set to None
"""
config = ApplicationConfiguration.objects.first()
assert config is not None
config.remote_ocr_api_key = "1234567890"
config.save()
# Test with all *
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_api_key": "*" * 32,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config.refresh_from_db()
self.assertEqual(config.remote_ocr_api_key, "1234567890")
# Test with empty string
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps(
{
"remote_ocr_api_key": "",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config.refresh_from_db()
self.assertEqual(config.remote_ocr_api_key, None)
def test_enable_ai_index_triggers_update(self) -> None: def test_enable_ai_index_triggers_update(self) -> None:
""" """
GIVEN: GIVEN:
+139
View File
@@ -1360,6 +1360,145 @@ class TestBulkEditObjectPermissions(APITestCase):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content, b"Insufficient permissions") self.assertEqual(response.content, b"Insufficient permissions")
def test_bulk_edit_object_permissions_shared_object_not_owner(self) -> None:
"""
GIVEN:
- Object owned by another user, shared with the logged in user with
change permissions
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions operation
THEN:
- User is not able to take ownership or change permissions, consistent
with the single object API
"""
self.t1.owner = self.user2
self.t1.save()
assign_perm("view_tag", self.user1, self.t1)
assign_perm("change_tag", self.user1, self.t1)
self.user1.user_permissions.add(
*Permission.objects.filter(
codename__in=["view_tag", "change_tag"],
),
)
user1 = User.objects.get(pk=self.user1.pk)
self.client.force_authenticate(user=user1)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "set_permissions",
"owner": user1.id,
"permissions": {
"view": {"users": [user1.id], "groups": []},
"change": {"users": [user1.id], "groups": []},
},
"merge": False,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(Tag.objects.get(pk=self.t1.id).owner, self.user2)
# the single object endpoint refuses the same request
response = self.client.patch(
f"/api/tags/{self.t1.id}/",
{"owner": user1.id},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(Tag.objects.get(pk=self.t1.id).owner, self.user2)
def test_bulk_edit_object_permissions_all_with_shared_objects(self) -> None:
"""
GIVEN:
- Objects owned by the logged in user, unowned objects and objects owned
by another user but shared with the logged in user
WHEN:
- bulk_edit_objects API endpoint is called with set_permissions operation
and all = True
THEN:
- The request is refused and no objects are changed
"""
owned = Tag.objects.create(name="owned", owner=self.user1)
shared = Tag.objects.create(name="shared", owner=self.user2)
assign_perm("view_tag", self.user1, shared)
assign_perm("change_tag", self.user1, shared)
self.user1.user_permissions.add(
*Permission.objects.filter(
codename__in=["view_tag", "change_tag"],
),
)
user1 = User.objects.get(pk=self.user1.pk)
self.client.force_authenticate(user=user1)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [],
"all": True,
"object_type": "tags",
"operation": "set_permissions",
"permissions": {
"view": {"users": [self.user3.id], "groups": []},
"change": {"users": [self.user3.id], "groups": []},
},
"merge": False,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# nothing was changed, including the objects the user does own
self.assertNotIn(self.user3, get_users_with_perms(owned))
self.assertNotIn(self.user3, get_users_with_perms(self.t1))
self.assertNotIn(self.user3, get_users_with_perms(shared))
self.assertEqual(Tag.objects.get(pk=shared.pk).owner, self.user2)
def test_bulk_edit_object_delete_shared_object_not_owner(self) -> None:
"""
GIVEN:
- Object owned by another user, shared with the logged in user with
change and delete permissions
WHEN:
- bulk_edit_objects API endpoint is called with delete operation
THEN:
- User is not able to delete the object, consistent with documents
"""
self.t1.owner = self.user2
self.t1.save()
assign_perm("view_tag", self.user1, self.t1)
assign_perm("change_tag", self.user1, self.t1)
assign_perm("delete_tag", self.user1, self.t1)
self.user1.user_permissions.add(
*Permission.objects.filter(
codename__in=["view_tag", "change_tag", "delete_tag"],
),
)
user1 = User.objects.get(pk=self.user1.pk)
self.client.force_authenticate(user=user1)
response = self.client.post(
"/api/bulk_edit_objects/",
json.dumps(
{
"objects": [self.t1.id],
"object_type": "tags",
"operation": "delete",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(Tag.objects.filter(pk=self.t1.id).exists())
def test_bulk_edit_object_permissions_validation(self) -> None: def test_bulk_edit_object_permissions_validation(self) -> None:
""" """
GIVEN: GIVEN:
@@ -677,16 +677,54 @@ class TestBulkEditObjectsApplyToAllPermissionBoundary:
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client): def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
owner = User.objects.create_user(username="tags_owner") owner = User.objects.create_user(username="tags_owner")
requester = User.objects.create_user(username="tags_requester") requester = User.objects.create_user(username="tags_requester")
new_owner = User.objects.create_user(username="tags_new_owner")
# grant the global change_tag permission so the object-level # grant the global change_tag permission so the object-level
# filtering (not the global has_perm check) is what's under test # filtering (not the global has_perm check) is what's under test
requester.user_permissions.add( requester.user_permissions.add(
Permission.objects.get(codename="change_tag"), Permission.objects.get(codename="change_tag"),
) )
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
visible = TagFactory(owner=owner) visible = TagFactory(owner=requester)
hidden = TagFactory(owner=owner) hidden = TagFactory(owner=owner)
assign_perm("view_tag", requester, visible)
assign_perm("change_tag", requester, visible) response = rest_api_client.post(
"/api/bulk_edit_objects/",
{
"object_type": "tags",
"operation": "set_permissions",
"all": True,
"filters": {},
"owner": new_owner.pk,
},
format="json",
)
assert response.status_code == HTTPStatus.OK
# The apply_to_all dispatch must resolve permitted objects up front:
# the requester's own tag gets its owner reassigned, while the tag
# owned by someone else is excluded entirely and keeps its owner.
visible.refresh_from_db()
hidden.refresh_from_db()
assert visible.owner == new_owner
assert hidden.owner == owner
def test_apply_to_all_tags_refuses_shared_but_unowned_tag(self, rest_api_client):
"""
A tag owned by someone else but shared with the requester is inside the
permitted set, so it reaches the ownership gate and fails the whole
request rather than being silently skipped. Editing permissions is
limited to the owner, same as documents.
"""
owner = User.objects.create_user(username="shared_tags_owner")
requester = User.objects.create_user(username="shared_tags_requester")
requester.user_permissions.add(
Permission.objects.get(codename="change_tag"),
)
rest_api_client.force_authenticate(user=requester)
owned = TagFactory(owner=requester)
shared = TagFactory(owner=owner)
assign_perm("view_tag", requester, shared)
assign_perm("change_tag", requester, shared)
response = rest_api_client.post( response = rest_api_client.post(
"/api/bulk_edit_objects/", "/api/bulk_edit_objects/",
@@ -699,16 +737,12 @@ class TestBulkEditObjectsApplyToAllPermissionBoundary:
}, },
format="json", format="json",
) )
assert response.status_code == HTTPStatus.OK assert response.status_code == HTTPStatus.FORBIDDEN
# The apply_to_all dispatch must resolve permitted objects up front: owned.refresh_from_db()
# the visible tag (object-level change_tag granted) gets its owner shared.refresh_from_db()
# reassigned, while the hidden tag (no object-level grant) is assert shared.owner == owner
# excluded entirely and keeps its original owner. assert owned.owner == requester
visible.refresh_from_db()
hidden.refresh_from_db()
assert visible.owner == requester
assert hidden.owner == owner
@pytest.mark.django_db @pytest.mark.django_db
@@ -720,8 +754,8 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
""" """
GIVEN: GIVEN:
- A tag hierarchy (parent -> permitted_child, unpermitted_child) - A tag hierarchy (parent -> permitted_child, unpermitted_child)
- A non-superuser requester with object-level change_tag granted - A non-superuser requester who owns the parent and only ONE of
on the parent and on only ONE of the two children the two children
WHEN: WHEN:
- bulk_edit_objects is called with all=True and a filter that - bulk_edit_objects is called with all=True and a filter that
matches only the root (parent) tag, engaging the matches only the root (parent) tag, engaging the
@@ -743,6 +777,7 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
""" """
owner = User.objects.create_user(username="tag_hierarchy_owner") owner = User.objects.create_user(username="tag_hierarchy_owner")
requester = User.objects.create_user(username="tag_hierarchy_requester") requester = User.objects.create_user(username="tag_hierarchy_requester")
new_owner = User.objects.create_user(username="tag_hierarchy_new_owner")
# global change_tag permission so the has_perm() gate passes and the # global change_tag permission so the has_perm() gate passes and the
# object-level permitted_object_ids filtering is what's under test # object-level permitted_object_ids filtering is what's under test
requester.user_permissions.add( requester.user_permissions.add(
@@ -750,9 +785,9 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
) )
rest_api_client.force_authenticate(user=requester) rest_api_client.force_authenticate(user=requester)
parent = TagFactory(owner=owner, name="parent-tag") parent = TagFactory(owner=requester, name="parent-tag")
permitted_child = TagFactory( permitted_child = TagFactory(
owner=owner, owner=requester,
name="permitted-child-tag", name="permitted-child-tag",
tn_parent=parent, tn_parent=parent,
) )
@@ -761,9 +796,6 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
name="unpermitted-child-tag", name="unpermitted-child-tag",
tn_parent=parent, tn_parent=parent,
) )
assign_perm("change_tag", requester, parent)
assign_perm("change_tag", requester, permitted_child)
# unpermitted_child is intentionally NOT granted change_tag
response = rest_api_client.post( response = rest_api_client.post(
"/api/bulk_edit_objects/", "/api/bulk_edit_objects/",
@@ -772,7 +804,7 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
"operation": "set_permissions", "operation": "set_permissions",
"all": True, "all": True,
"filters": {"is_root": True}, "filters": {"is_root": True},
"owner": requester.pk, "owner": new_owner.pk,
}, },
format="json", format="json",
) )
@@ -781,8 +813,8 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
parent.refresh_from_db() parent.refresh_from_db()
permitted_child.refresh_from_db() permitted_child.refresh_from_db()
unpermitted_child.refresh_from_db() unpermitted_child.refresh_from_db()
assert parent.owner == requester assert parent.owner == new_owner
assert permitted_child.owner == requester assert permitted_child.owner == new_owner
assert unpermitted_child.owner == owner assert unpermitted_child.owner == owner
+2 -1
View File
@@ -4879,10 +4879,11 @@ class BulkEditObjectsView(PassUserMixin):
if not user.is_superuser: if not user.is_superuser:
perm = f"documents.{perm_codename}" perm = f"documents.{perm_codename}"
# Limited to the owner (or unowned), same as documents, see BulkEditView
has_perms = ( has_perms = (
user.has_perm(perm) user.has_perm(perm)
and not objs.exclude( and not objs.exclude(
pk__in=permitted_object_ids(user, object_class, perm_codename), Q(owner=user) | Q(owner__isnull=True),
).exists() ).exists()
) )
+38 -38
View File
@@ -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-08-19 14:23+0000\n" "POT-Creation-Date: 2026-08-19 18:18+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"
@@ -2170,151 +2170,151 @@ msgstr ""
msgid "paperless application settings" msgid "paperless application settings"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:556 #: paperless/settings/__init__.py:562
msgid "English (US)" msgid "English (US)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:557 #: paperless/settings/__init__.py:563
msgid "Arabic" msgid "Arabic"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:558 #: paperless/settings/__init__.py:564
msgid "Afrikaans" msgid "Afrikaans"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:559 #: paperless/settings/__init__.py:565
msgid "Belarusian" msgid "Belarusian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:560 #: paperless/settings/__init__.py:566
msgid "Bulgarian" msgid "Bulgarian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:561 #: paperless/settings/__init__.py:567
msgid "Catalan" msgid "Catalan"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:562 #: paperless/settings/__init__.py:568
msgid "Czech" msgid "Czech"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:563 #: paperless/settings/__init__.py:569
msgid "Danish" msgid "Danish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:564 #: paperless/settings/__init__.py:570
msgid "German" msgid "German"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:565 #: paperless/settings/__init__.py:571
msgid "Greek" msgid "Greek"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:566 #: paperless/settings/__init__.py:572
msgid "English (GB)" msgid "English (GB)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:567 #: paperless/settings/__init__.py:573
msgid "Spanish" msgid "Spanish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:568 #: paperless/settings/__init__.py:574
msgid "Persian" msgid "Persian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:569 #: paperless/settings/__init__.py:575
msgid "Finnish" msgid "Finnish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:570 #: paperless/settings/__init__.py:576
msgid "French" msgid "French"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:571 #: paperless/settings/__init__.py:577
msgid "Hungarian" msgid "Hungarian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:572 #: paperless/settings/__init__.py:578
msgid "Indonesian" msgid "Indonesian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:573 #: paperless/settings/__init__.py:579
msgid "Italian" msgid "Italian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:574 #: paperless/settings/__init__.py:580
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:575 #: paperless/settings/__init__.py:581
msgid "Korean" msgid "Korean"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:576 #: paperless/settings/__init__.py:582
msgid "Luxembourgish" msgid "Luxembourgish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:577 #: paperless/settings/__init__.py:583
msgid "Norwegian" msgid "Norwegian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:578 #: paperless/settings/__init__.py:584
msgid "Dutch" msgid "Dutch"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:579 #: paperless/settings/__init__.py:585
msgid "Polish" msgid "Polish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:580 #: paperless/settings/__init__.py:586
msgid "Portuguese (Brazil)" msgid "Portuguese (Brazil)"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:581 #: paperless/settings/__init__.py:587
msgid "Portuguese" msgid "Portuguese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:582 #: paperless/settings/__init__.py:588
msgid "Romanian" msgid "Romanian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:583 #: paperless/settings/__init__.py:589
msgid "Russian" msgid "Russian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:584 #: paperless/settings/__init__.py:590
msgid "Slovak" msgid "Slovak"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:585 #: paperless/settings/__init__.py:591
msgid "Slovenian" msgid "Slovenian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:586 #: paperless/settings/__init__.py:592
msgid "Serbian" msgid "Serbian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:587 #: paperless/settings/__init__.py:593
msgid "Swedish" msgid "Swedish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:588 #: paperless/settings/__init__.py:594
msgid "Turkish" msgid "Turkish"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:589 #: paperless/settings/__init__.py:595
msgid "Ukrainian" msgid "Ukrainian"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:590 #: paperless/settings/__init__.py:596
msgid "Vietnamese" msgid "Vietnamese"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:591 #: paperless/settings/__init__.py:597
msgid "Chinese Simplified" msgid "Chinese Simplified"
msgstr "" msgstr ""
#: paperless/settings/__init__.py:592 #: paperless/settings/__init__.py:598
msgid "Chinese Traditional" msgid "Chinese Traditional"
msgstr "" msgstr ""
+14
View File
@@ -337,6 +337,20 @@ def check_deprecated_v2_ocr_env_vars(
return warnings return warnings
@register()
def check_remote_parser_configured(app_configs: Any, **kwargs: Any) -> list[Error]:
if settings.REMOTE_OCR_ENGINE == "azureai" and not (
settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY
):
return [
Error(
"Azure AI remote parser requires endpoint and API key to be configured.",
),
]
return []
def get_tesseract_langs(): def get_tesseract_langs():
proc = subprocess.run( proc = subprocess.run(
[shutil.which("tesseract"), "--list-langs"], [shutil.which("tesseract"), "--list-langs"],
-24
View File
@@ -185,30 +185,6 @@ class GeneralConfig(BaseConfig):
self.app_logo = app_config.app_logo.url if app_config.app_logo else None self.app_logo = app_config.app_logo.url if app_config.app_logo else None
@dataclasses.dataclass
class RemoteOCRConfig(BaseConfig):
"""
Settings for the remote (cloud) OCR parser
"""
remote_ocr_engine: str | None = dataclasses.field(init=False)
remote_ocr_api_key: str | None = dataclasses.field(init=False)
remote_ocr_endpoint: str | None = dataclasses.field(init=False)
def __post_init__(self) -> None:
app_config = self._get_config_instance()
self.remote_ocr_engine = (
app_config.remote_ocr_engine or settings.REMOTE_OCR_ENGINE
)
self.remote_ocr_api_key = (
app_config.remote_ocr_api_key or settings.REMOTE_OCR_API_KEY
)
self.remote_ocr_endpoint = (
app_config.remote_ocr_endpoint or settings.REMOTE_OCR_ENDPOINT
)
@dataclasses.dataclass @dataclasses.dataclass
class AIConfig(BaseConfig): class AIConfig(BaseConfig):
""" """
@@ -1,44 +0,0 @@
# Generated by Django 5.2.16 on 2026-08-10 14:37
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("paperless", "0013_applicationconfiguration_llm_request_timeout"),
]
operations = [
migrations.AddField(
model_name="applicationconfiguration",
name="remote_ocr_api_key",
field=models.CharField(
blank=True,
max_length=1024,
null=True,
verbose_name="Sets the remote OCR API key",
),
),
migrations.AddField(
model_name="applicationconfiguration",
name="remote_ocr_endpoint",
field=models.CharField(
blank=True,
max_length=256,
null=True,
verbose_name="Sets the remote OCR endpoint",
),
),
migrations.AddField(
model_name="applicationconfiguration",
name="remote_ocr_engine",
field=models.CharField(
blank=True,
choices=[("azureai", "Azure AI Document Intelligence")],
max_length=32,
null=True,
verbose_name="Sets the remote OCR engine",
),
),
]
-37
View File
@@ -74,14 +74,6 @@ class ColorConvertChoices(models.TextChoices):
CMYK = ("CMYK", _("CMYK")) CMYK = ("CMYK", _("CMYK"))
class RemoteOCREngine(models.TextChoices):
"""
Matches to PAPERLESS_REMOTE_OCR_ENGINE
"""
AZURE_AI = ("azureai", _("Azure AI Document Intelligence"))
class LLMEmbeddingBackend(models.TextChoices): class LLMEmbeddingBackend(models.TextChoices):
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible")) OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
HUGGINGFACE = ("huggingface", _("Huggingface")) HUGGINGFACE = ("huggingface", _("Huggingface"))
@@ -294,35 +286,6 @@ class ApplicationConfiguration(AbstractSingletonModel):
null=True, null=True,
) )
"""
Settings for the remote OCR parser
"""
# PAPERLESS_REMOTE_OCR_ENGINE
remote_ocr_engine = models.CharField(
verbose_name=_("Sets the remote OCR engine"),
blank=True,
null=True,
max_length=32,
choices=RemoteOCREngine.choices,
)
# PAPERLESS_REMOTE_OCR_API_KEY
remote_ocr_api_key = models.CharField(
verbose_name=_("Sets the remote OCR API key"),
blank=True,
null=True,
max_length=1024,
)
# PAPERLESS_REMOTE_OCR_ENDPOINT
remote_ocr_endpoint = models.CharField(
verbose_name=_("Sets the remote OCR endpoint"),
blank=True,
null=True,
max_length=256,
)
""" """
AI related settings AI related settings
""" """
+10 -14
View File
@@ -61,18 +61,6 @@ class RemoteEngineConfig:
self.api_key = api_key self.api_key = api_key
self.endpoint = endpoint self.endpoint = endpoint
@classmethod
def from_app_config(cls) -> Self:
"""Build the config from the app config, falling back to the env."""
from paperless.config import RemoteOCRConfig
app_config = RemoteOCRConfig()
return cls(
engine=app_config.remote_ocr_engine,
api_key=app_config.remote_ocr_api_key,
endpoint=app_config.remote_ocr_endpoint,
)
def engine_is_valid(self) -> bool: def engine_is_valid(self) -> bool:
"""Return True when the engine is known and fully configured.""" """Return True when the engine is known and fully configured."""
return ( return (
@@ -157,7 +145,11 @@ class RemoteDocumentParser:
20 when the remote engine is configured and the MIME type is 20 when the remote engine is configured and the MIME type is
supported, otherwise None. supported, otherwise None.
""" """
config = RemoteEngineConfig.from_app_config() config = RemoteEngineConfig(
engine=settings.REMOTE_OCR_ENGINE,
api_key=settings.REMOTE_OCR_API_KEY,
endpoint=settings.REMOTE_OCR_ENDPOINT,
)
if not config.engine_is_valid(): if not config.engine_is_valid():
return None return None
if mime_type not in _SUPPORTED_MIME_TYPES: if mime_type not in _SUPPORTED_MIME_TYPES:
@@ -252,7 +244,11 @@ class RemoteDocumentParser:
Whether an archive copy is wanted. For PDFs, False skips the Whether an archive copy is wanted. For PDFs, False skips the
remote engine and uses locally-extracted text instead. remote engine and uses locally-extracted text instead.
""" """
config = RemoteEngineConfig.from_app_config() config = RemoteEngineConfig(
engine=settings.REMOTE_OCR_ENGINE,
api_key=settings.REMOTE_OCR_API_KEY,
endpoint=settings.REMOTE_OCR_ENDPOINT,
)
if not config.engine_is_valid(): if not config.engine_is_valid():
logger.warning( logger.warning(
+5 -14
View File
@@ -219,13 +219,6 @@ class ApplicationConfigurationSerializer(
allow_null=True, allow_null=True,
max_length=1024, max_length=1024,
) )
remote_ocr_api_key = ObfuscatedPasswordField(
required=False,
allow_null=True,
max_length=1024,
)
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
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
@@ -237,13 +230,11 @@ class ApplicationConfigurationSerializer(
data["language"] = None data["language"] = None
if "llm_output_language" in data and data["llm_output_language"] == "": if "llm_output_language" in data and data["llm_output_language"] == "":
data["llm_output_language"] = None data["llm_output_language"] = None
for field in self.OBFUSCATED_FIELDS: if "llm_api_key" in data and data["llm_api_key"] is not None:
if field in data and data[field] is not None: if data["llm_api_key"] == "":
if data[field] == "": data["llm_api_key"] = None
data[field] = None elif len(data["llm_api_key"].replace("*", "")) == 0:
# Not a real value, don't overwrite the stored one del data["llm_api_key"]
elif len(data[field].replace("*", "")) == 0:
del data[field]
return super().run_validation(data) return super().run_validation(data)
def update(self, instance, validated_data): def update(self, instance, validated_data):
+10
View File
@@ -1,6 +1,7 @@
import datetime import datetime
import logging import logging
import os import os
from hashlib import sha256
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -172,6 +173,15 @@ def parse_beat_schedule() -> dict:
# Don't add disabled tasks to the schedule # Don't add disabled tasks to the schedule
if value == "disable": if value == "disable":
continue continue
if (
task["env_key"] == "PAPERLESS_EMAIL_TASK_CRON"
and task["env_key"] not in os.environ
):
# Spread default polling across the ten-minute interval.
secret = os.environ["PAPERLESS_SECRET_KEY"].encode()
offset = int.from_bytes(sha256(secret).digest()) % 10
minutes = ",".join(str(minute) for minute in range(offset, 60, 10))
value = f"{minutes} * * * *"
# I find https://crontab.guru/ super helpful # I find https://crontab.guru/ super helpful
# crontab(5) format # crontab(5) format
# - five time-and-date fields # - five time-and-date fields
+2 -24
View File
@@ -114,26 +114,7 @@ def remote_parser() -> Generator[RemoteDocumentParser, None, None]:
@pytest.fixture() @pytest.fixture()
def empty_remote_ocr_app_config(mocker: MockerFixture) -> MagicMock: def azure_settings(settings: SettingsWrapper) -> SettingsWrapper:
# empty app config without accessing db
app_config = mocker.MagicMock(
remote_ocr_engine=None,
remote_ocr_api_key=None,
remote_ocr_endpoint=None,
remote_ocr_mode=None,
)
mocker.patch(
"paperless.config.BaseConfig._get_config_instance",
return_value=app_config,
)
return app_config
@pytest.fixture()
def azure_settings(
settings: SettingsWrapper,
empty_remote_ocr_app_config: MagicMock,
) -> SettingsWrapper:
"""Configure Django settings for a valid Azure AI OCR engine. """Configure Django settings for a valid Azure AI OCR engine.
Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and
@@ -152,10 +133,7 @@ def azure_settings(
@pytest.fixture() @pytest.fixture()
def no_engine_settings( def no_engine_settings(settings: SettingsWrapper) -> SettingsWrapper:
settings: SettingsWrapper,
empty_remote_ocr_app_config: MagicMock,
) -> SettingsWrapper:
"""Configure Django settings with no remote engine configured. """Configure Django settings with no remote engine configured.
Returns Returns
@@ -21,7 +21,6 @@ from unittest.mock import Mock
import pytest import pytest
from documents.parsers import ParseError from documents.parsers import ParseError
from paperless.models import ApplicationConfiguration
from paperless.parsers import ParserContext from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol from paperless.parsers import ParserProtocol
from paperless.parsers.remote import RemoteDocumentParser from paperless.parsers.remote import RemoteDocumentParser
@@ -199,21 +198,21 @@ class TestRemoteParserScore:
def test_score_returns_none_when_api_key_missing( def test_score_returns_none_when_api_key_missing(
self, self,
no_engine_settings: SettingsWrapper, settings: SettingsWrapper,
) -> None: ) -> None:
no_engine_settings.REMOTE_OCR_ENGINE = "azureai" settings.REMOTE_OCR_ENGINE = "azureai"
no_engine_settings.REMOTE_OCR_ENDPOINT = ( settings.REMOTE_OCR_API_KEY = None
"https://test.cognitiveservices.azure.com" settings.REMOTE_OCR_ENDPOINT = "https://test.cognitiveservices.azure.com"
)
result = RemoteDocumentParser.score("application/pdf", "doc.pdf") result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
assert result is None assert result is None
def test_score_returns_none_when_endpoint_missing( def test_score_returns_none_when_endpoint_missing(
self, self,
no_engine_settings: SettingsWrapper, settings: SettingsWrapper,
) -> None: ) -> None:
no_engine_settings.REMOTE_OCR_ENGINE = "azureai" settings.REMOTE_OCR_ENGINE = "azureai"
no_engine_settings.REMOTE_OCR_API_KEY = "key" settings.REMOTE_OCR_API_KEY = "key"
settings.REMOTE_OCR_ENDPOINT = None
result = RemoteDocumentParser.score("application/pdf", "doc.pdf") result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
assert result is None assert result is None
@@ -228,24 +227,6 @@ class TestRemoteParserScore:
score = RemoteDocumentParser.score("application/pdf", "doc.pdf") score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
assert score is not None and score > 10 assert score is not None and score > 10
@pytest.mark.django_db
def test_score_uses_app_config_when_env_unset(
self,
settings: SettingsWrapper,
) -> None:
"""The app config alone is enough to activate the parser."""
settings.REMOTE_OCR_ENGINE = None
settings.REMOTE_OCR_API_KEY = None
settings.REMOTE_OCR_ENDPOINT = None
config = ApplicationConfiguration.objects.first()
assert config is not None
config.remote_ocr_engine = "azureai"
config.remote_ocr_api_key = "app-config-key"
config.remote_ocr_endpoint = "https://config.cognitiveservices.azure.com"
config.save()
assert RemoteDocumentParser.score("application/pdf", "doc.pdf") == 20
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Properties # Properties
@@ -1277,8 +1277,6 @@ class TestParserFileTypes:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Remote ocr config from ApplicationConfiguration needs DB access
@pytest.mark.django_db
class TestRasterisedDocumentParserRegistry: class TestRasterisedDocumentParserRegistry:
def test_registered_in_defaults(self) -> None: def test_registered_in_defaults(self) -> None:
from paperless.parsers.registry import ParserRegistry from paperless.parsers.registry import ParserRegistry
@@ -168,6 +168,7 @@ class TestParseHostingSettings:
def make_expected_schedule( def make_expected_schedule(
overrides: dict[str, dict[str, Any]] | None = None, overrides: dict[str, dict[str, Any]] | None = None,
disabled: set[str] | None = None, disabled: set[str] | None = None,
email_minute: str = "6,16,26,36,46,56",
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Build the expected schedule with optional overrides and disabled tasks. Build the expected schedule with optional overrides and disabled tasks.
@@ -185,7 +186,7 @@ def make_expected_schedule(
schedule: dict[str, Any] = { schedule: dict[str, Any] = {
"Check all e-mail accounts": { "Check all e-mail accounts": {
"task": "paperless_mail.tasks.process_mail_accounts", "task": "paperless_mail.tasks.process_mail_accounts",
"schedule": crontab(minute="*/10"), "schedule": crontab(minute=email_minute),
"options": { "options": {
"expires": mail_expire, "expires": mail_expire,
"headers": {"trigger_source": "scheduled"}, "headers": {"trigger_source": "scheduled"},
@@ -266,6 +267,11 @@ class TestParseBeatSchedule:
("env", "expected"), ("env", "expected"),
[ [
pytest.param({}, make_expected_schedule(), id="defaults"), pytest.param({}, make_expected_schedule(), id="defaults"),
pytest.param(
{"PAPERLESS_EMAIL_TASK_CRON": "*/10 * * * *"},
make_expected_schedule(email_minute="*/10"),
id="email-explicit-default",
),
pytest.param( pytest.param(
{"PAPERLESS_EMAIL_TASK_CRON": "*/50 * * * mon"}, {"PAPERLESS_EMAIL_TASK_CRON": "*/50 * * * mon"},
make_expected_schedule( make_expected_schedule(
@@ -304,7 +310,11 @@ class TestParseBeatSchedule:
expected: dict[str, Any], expected: dict[str, Any],
mocker: MockerFixture, mocker: MockerFixture,
) -> None: ) -> None:
mocker.patch.dict(os.environ, env, clear=False) mocker.patch.dict(
os.environ,
{"PAPERLESS_SECRET_KEY": "test-secret", **env},
clear=False,
)
schedule = parse_beat_schedule() schedule = parse_beat_schedule()
assert schedule == expected assert schedule == expected
+26
View File
@@ -15,6 +15,7 @@ from paperless.checks import audit_log_check
from paperless.checks import binaries_check from paperless.checks import binaries_check
from paperless.checks import check_default_language_available from paperless.checks import check_default_language_available
from paperless.checks import check_deprecated_db_settings from paperless.checks import check_deprecated_db_settings
from paperless.checks import check_remote_parser_configured
from paperless.checks import check_v3_minimum_upgrade_version from paperless.checks import check_v3_minimum_upgrade_version
from paperless.checks import debug_mode_check from paperless.checks import debug_mode_check
from paperless.checks import paths_check from paperless.checks import paths_check
@@ -630,6 +631,31 @@ class TestV3MinimumUpgradeVersionCheck:
assert check_v3_minimum_upgrade_version(None) == [] assert check_v3_minimum_upgrade_version(None) == []
class TestRemoteParserChecks:
def test_no_engine(self, settings: SettingsWrapper) -> None:
settings.REMOTE_OCR_ENGINE = None
msgs = check_remote_parser_configured(None)
assert len(msgs) == 0
def test_azure_no_endpoint(self, settings: SettingsWrapper) -> None:
settings.REMOTE_OCR_ENGINE = "azureai"
settings.REMOTE_OCR_API_KEY = "somekey"
settings.REMOTE_OCR_ENDPOINT = None
msgs = check_remote_parser_configured(None)
assert len(msgs) == 1
msg = msgs[0]
assert (
"Azure AI remote parser requires endpoint and API key to be configured."
in msg.msg
)
class TestTesseractChecks: class TestTesseractChecks:
def test_default_language(self) -> None: def test_default_language(self) -> None:
check_default_language_available(None) check_default_language_available(None)
@@ -1,88 +0,0 @@
"""Tests for RemoteOCRConfig precedence between app config and Django settings."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from django.test import override_settings
from paperless.config import RemoteOCRConfig
if TYPE_CHECKING:
from unittest.mock import MagicMock
@pytest.fixture()
def null_app_config(mocker) -> MagicMock:
"""Mock ApplicationConfiguration with all fields None → falls back to Django settings."""
return mocker.MagicMock(
remote_ocr_engine=None,
remote_ocr_api_key=None,
remote_ocr_endpoint=None,
)
@pytest.fixture()
def make_remote_ocr_config(mocker):
def _make(app_config, **django_settings_overrides):
mocker.patch(
"paperless.config.BaseConfig._get_config_instance",
return_value=app_config,
)
with override_settings(**django_settings_overrides):
return RemoteOCRConfig()
return _make
class TestRemoteOCRConfig:
def test_falls_back_to_settings(
self,
make_remote_ocr_config,
null_app_config,
) -> None:
cfg = make_remote_ocr_config(
null_app_config,
REMOTE_OCR_ENGINE="azureai",
REMOTE_OCR_API_KEY="env-key",
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
)
assert cfg.remote_ocr_engine == "azureai"
assert cfg.remote_ocr_api_key == "env-key"
assert cfg.remote_ocr_endpoint == "https://env.cognitiveservices.azure.com"
def test_app_config_takes_precedence(
self,
make_remote_ocr_config,
mocker,
) -> None:
app_config = mocker.MagicMock(
remote_ocr_engine="azureai",
remote_ocr_api_key="db-key",
remote_ocr_endpoint="https://db.cognitiveservices.azure.com",
)
cfg = make_remote_ocr_config(
app_config,
REMOTE_OCR_ENGINE=None,
REMOTE_OCR_API_KEY="env-key",
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
)
assert cfg.remote_ocr_engine == "azureai"
assert cfg.remote_ocr_api_key == "db-key"
assert cfg.remote_ocr_endpoint == "https://db.cognitiveservices.azure.com"
def test_unset_everywhere(
self,
make_remote_ocr_config,
null_app_config,
) -> None:
cfg = make_remote_ocr_config(
null_app_config,
REMOTE_OCR_ENGINE=None,
REMOTE_OCR_API_KEY=None,
REMOTE_OCR_ENDPOINT=None,
)
assert cfg.remote_ocr_engine is None
assert cfg.remote_ocr_api_key is None
assert cfg.remote_ocr_endpoint is None