mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-22 10:43:18 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a34318e3 | ||
|
|
bbcd6af2fe | ||
|
|
0431939f18 | ||
|
|
bed95ea301 | ||
|
|
42034c3c77 | ||
|
|
705220fb5a | ||
|
|
a424dace43 | ||
|
|
751299895e |
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+4
@@ -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>
|
||||||
|
|||||||
+41
@@ -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
|
||||||
|
|||||||
+7
-1
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user