mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-14 13:11:24 +00:00
Compare commits
9 Commits
fix-bulk-e
...
feature-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2098a11eb1 | ||
|
|
af8a8e791b | ||
|
|
8d4163bef3 | ||
|
|
e9e1d4ccca | ||
|
|
c955ba7d07 | ||
|
|
7028bb2163 | ||
|
|
5d4d87764c | ||
|
|
75dce7f19f | ||
|
|
365ff99934 |
@@ -437,6 +437,3 @@ Initial API version.
|
|||||||
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
|
moved from the bulk edit endpoint to their own individual endpoints. Using these methods via
|
||||||
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
|
the bulk edit endpoint is still supported for compatibility with versions < 10 until support
|
||||||
for API v9 is dropped.
|
for API v9 is dropped.
|
||||||
- The `all` parameter of list endpoints is now deprecated and will be removed in a future version.
|
|
||||||
- The bulk edit objects endpoint now supports `all` and `filters` parameters to avoid having to send
|
|
||||||
large lists of object IDs for operations affecting many objects.
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ dependencies = [
|
|||||||
"llama-index-llms-openai>=0.6.13",
|
"llama-index-llms-openai>=0.6.13",
|
||||||
"llama-index-vector-stores-faiss>=0.5.2",
|
"llama-index-vector-stores-faiss>=0.5.2",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.9.1",
|
||||||
"ocrmypdf~=16.13.0",
|
"ocrmypdf~=17.3.0",
|
||||||
"openai>=1.76",
|
"openai>=1.76",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
|
|||||||
@@ -468,7 +468,7 @@
|
|||||||
"time": 0.951,
|
"time": 0.951,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -534,7 +534,7 @@
|
|||||||
"time": 0.653,
|
"time": 0.653,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
@@ -883,7 +883,7 @@
|
|||||||
"time": 0.93,
|
"time": 0.93,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
@@ -961,7 +961,7 @@
|
|||||||
"time": -1,
|
"time": -1,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=4",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=4",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ test('basic filtering', async ({ page }) => {
|
|||||||
await expect(page).toHaveURL(/tags__id__all=9/)
|
await expect(page).toHaveURL(/tags__id__all=9/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
await page.getByRole('button', { name: 'Document type' }).click()
|
await page.getByRole('button', { name: 'Document type' }).click()
|
||||||
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
|
await page.getByRole('menuitem', { name: 'Invoice Test 3' }).click()
|
||||||
await expect(page).toHaveURL(/document_type__id__in=1/)
|
await expect(page).toHaveURL(/document_type__id__in=1/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
||||||
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -20,9 +20,9 @@ import { Subject, filter, takeUntil } from 'rxjs'
|
|||||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
import { SelectionDataItem } from 'src/app/data/results'
|
|
||||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||||
|
import { SelectionDataItem } from 'src/app/services/rest/document.service'
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_tags: [101], remove_tags: [] },
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -332,7 +332,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -423,7 +423,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { correspondent: 101 },
|
parameters: { correspondent: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -455,7 +455,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -521,7 +521,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { document_type: 101 },
|
parameters: { document_type: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -553,7 +553,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -619,7 +619,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { storage_path: 101 },
|
parameters: { storage_path: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -651,7 +651,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -717,7 +717,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -749,7 +749,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -858,7 +858,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -951,7 +951,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -986,7 +986,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
source_mode: 'latest_version',
|
source_mode: 'latest_version',
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1027,7 +1027,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
metadata_document_id: 3,
|
metadata_document_id: 3,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1046,7 +1046,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
delete_originals: true,
|
delete_originals: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1067,7 +1067,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
archive_fallback: true,
|
archive_fallback: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1153,7 +1153,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1460,7 +1460,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
||||||
expect(listReloadSpy).toHaveBeenCalled()
|
expect(listReloadSpy).toHaveBeenCalled()
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
|||||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
import { CustomField } from 'src/app/data/custom-field'
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { SelectionDataItem } from 'src/app/data/results'
|
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
@@ -33,6 +32,7 @@ import {
|
|||||||
DocumentBulkEditMethod,
|
DocumentBulkEditMethod,
|
||||||
DocumentService,
|
DocumentService,
|
||||||
MergeDocumentsRequest,
|
MergeDocumentsRequest,
|
||||||
|
SelectionDataItem,
|
||||||
} from 'src/app/services/rest/document.service'
|
} from 'src/app/services/rest/document.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ import {
|
|||||||
FILTER_TITLE_CONTENT,
|
FILTER_TITLE_CONTENT,
|
||||||
NEGATIVE_NULL_FILTER_VALUE,
|
NEGATIVE_NULL_FILTER_VALUE,
|
||||||
} from 'src/app/data/filter-rule-type'
|
} from 'src/app/data/filter-rule-type'
|
||||||
import { SelectionData, SelectionDataItem } from 'src/app/data/results'
|
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
@@ -85,7 +84,11 @@ import {
|
|||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import {
|
||||||
|
DocumentService,
|
||||||
|
SelectionData,
|
||||||
|
SelectionDataItem,
|
||||||
|
} from 'src/app/services/rest/document.service'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||||
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
||||||
@if (activeManagementList.hasSelection) {
|
@if (activeManagementList.selectedObjects.size > 0) {
|
||||||
<pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<span class="input-group-text border-0" i18n>Select:</span>
|
<span class="input-group-text border-0" i18n>Select:</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm flex-nowrap">
|
<div class="btn-group btn-group-sm flex-nowrap">
|
||||||
@if (activeManagementList.hasSelection) {
|
@if (activeManagementList.selectedObjects.size > 0) {
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
|
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
|
||||||
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
|
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
|
||||||
</button>
|
</button>
|
||||||
@@ -40,11 +40,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
|
||||||
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || !activeManagementList.hasSelection">
|
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0">
|
||||||
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
|
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
|
||||||
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || !activeManagementList.hasSelection">
|
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0">
|
||||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"
|
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"
|
||||||
|
|||||||
@@ -65,8 +65,8 @@
|
|||||||
@if (displayCollectionSize > 0) {
|
@if (displayCollectionSize > 0) {
|
||||||
<div>
|
<div>
|
||||||
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
<ng-container i18n>{displayCollectionSize, plural, =1 {One {{typeName}}} other {{{displayCollectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||||
@if (hasSelection) {
|
@if (selectedObjects.size > 0) {
|
||||||
({{selectedCount}} selected)
|
({{selectedObjects.size}} selected)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ describe('ManagementListComponent', () => {
|
|||||||
: tags
|
: tags
|
||||||
return of({
|
return of({
|
||||||
count: results.length,
|
count: results.length,
|
||||||
|
all: results.map((o) => o.id),
|
||||||
results,
|
results,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -230,11 +231,11 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use API count for pagination and nested ids for displayed total', fakeAsync(() => {
|
it('should use API count for pagination and all ids for displayed total', fakeAsync(() => {
|
||||||
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
||||||
of({
|
of({
|
||||||
count: 1,
|
count: 1,
|
||||||
display_count: 3,
|
all: [1, 2, 3],
|
||||||
results: tags.slice(0, 1),
|
results: tags.slice(0, 1),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -314,17 +315,13 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(component.togggleAll).toBe(false)
|
expect(component.togggleAll).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selectAll should activate all-selection mode', () => {
|
it('selectAll should use all IDs when collection size exists', () => {
|
||||||
;(tagService.listFiltered as jest.Mock).mockClear()
|
;(component as any).allIDs = [1, 2, 3, 4]
|
||||||
component.collectionSize = tags.length
|
component.collectionSize = 4
|
||||||
|
|
||||||
component.selectAll()
|
component.selectAll()
|
||||||
|
|
||||||
expect(tagService.listFiltered).not.toHaveBeenCalled()
|
expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4]))
|
||||||
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
|
|
||||||
expect((component as any).allSelectionActive).toBe(true)
|
|
||||||
expect(component.hasSelection).toBe(true)
|
|
||||||
expect(component.selectedCount).toBe(tags.length)
|
|
||||||
expect(component.togggleAll).toBe(true)
|
expect(component.togggleAll).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -398,33 +395,6 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(successToastSpy).toHaveBeenCalled()
|
expect(successToastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support bulk edit permissions for all filtered items', () => {
|
|
||||||
const bulkEditPermsSpy = jest
|
|
||||||
.spyOn(tagService, 'bulk_edit_objects')
|
|
||||||
.mockReturnValue(of('OK'))
|
|
||||||
component.selectAll()
|
|
||||||
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.setPermissions()
|
|
||||||
expect(modal).not.toBeUndefined()
|
|
||||||
|
|
||||||
modal.componentInstance.confirmClicked.emit({
|
|
||||||
permissions: {},
|
|
||||||
merge: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(bulkEditPermsSpy).toHaveBeenCalledWith(
|
|
||||||
[],
|
|
||||||
BulkEditObjectOperation.SetPermissions,
|
|
||||||
{},
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
{ is_root: true }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support bulk delete objects', () => {
|
it('should support bulk delete objects', () => {
|
||||||
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
||||||
component.toggleSelected(tags[0])
|
component.toggleSelected(tags[0])
|
||||||
@@ -445,11 +415,7 @@ describe('ManagementListComponent', () => {
|
|||||||
modal.componentInstance.confirmClicked.emit(null)
|
modal.componentInstance.confirmClicked.emit(null)
|
||||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||||
Array.from(selected),
|
Array.from(selected),
|
||||||
BulkEditObjectOperation.Delete,
|
BulkEditObjectOperation.Delete
|
||||||
null,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
null
|
|
||||||
)
|
)
|
||||||
expect(errorToastSpy).toHaveBeenCalled()
|
expect(errorToastSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
@@ -460,29 +426,6 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(successToastSpy).toHaveBeenCalled()
|
expect(successToastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support bulk delete for all filtered items', () => {
|
|
||||||
const bulkEditSpy = jest
|
|
||||||
.spyOn(tagService, 'bulk_edit_objects')
|
|
||||||
.mockReturnValue(of('OK'))
|
|
||||||
|
|
||||||
component.selectAll()
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.delete()
|
|
||||||
expect(modal).not.toBeUndefined()
|
|
||||||
|
|
||||||
modal.componentInstance.confirmClicked.emit(null)
|
|
||||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
|
||||||
[],
|
|
||||||
BulkEditObjectOperation.Delete,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
{ is_root: true }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should disallow bulk permissions or delete objects if no global perms', () => {
|
it('should disallow bulk permissions or delete objects if no global perms', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
||||||
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
|
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
|
||||||
|
|||||||
@@ -90,8 +90,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
|
|
||||||
public data: T[] = []
|
public data: T[] = []
|
||||||
private unfilteredData: T[] = []
|
private unfilteredData: T[] = []
|
||||||
private currentExtraParams: { [key: string]: any } = null
|
private allIDs: number[] = []
|
||||||
private allSelectionActive = false
|
|
||||||
|
|
||||||
public page = 1
|
public page = 1
|
||||||
|
|
||||||
@@ -108,16 +107,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
public selectedObjects: Set<number> = new Set()
|
public selectedObjects: Set<number> = new Set()
|
||||||
public togggleAll: boolean = false
|
public togggleAll: boolean = false
|
||||||
|
|
||||||
public get hasSelection(): boolean {
|
|
||||||
return this.selectedObjects.size > 0 || this.allSelectionActive
|
|
||||||
}
|
|
||||||
|
|
||||||
public get selectedCount(): number {
|
|
||||||
return this.allSelectionActive
|
|
||||||
? this.displayCollectionSize
|
|
||||||
: this.selectedObjects.size
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.reloadData()
|
this.reloadData()
|
||||||
|
|
||||||
@@ -161,11 +150,11 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getCollectionSize(results: Results<T>): number {
|
protected getCollectionSize(results: Results<T>): number {
|
||||||
return results.count
|
return results.all?.length ?? results.count
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getDisplayCollectionSize(results: Results<T>): number {
|
protected getDisplayCollectionSize(results: Results<T>): number {
|
||||||
return results.display_count ?? this.getCollectionSize(results)
|
return this.getCollectionSize(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
getDocumentCount(object: MatchingModel): number {
|
getDocumentCount(object: MatchingModel): number {
|
||||||
@@ -182,7 +171,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
|
|
||||||
reloadData(extraParams: { [key: string]: any } = null) {
|
reloadData(extraParams: { [key: string]: any } = null) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.currentExtraParams = extraParams
|
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
this.service
|
this.service
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -201,6 +189,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.data = this.filterData(c.results)
|
this.data = this.filterData(c.results)
|
||||||
this.collectionSize = this.getCollectionSize(c)
|
this.collectionSize = this.getCollectionSize(c)
|
||||||
this.displayCollectionSize = this.getDisplayCollectionSize(c)
|
this.displayCollectionSize = this.getDisplayCollectionSize(c)
|
||||||
|
this.allIDs = c.all
|
||||||
}),
|
}),
|
||||||
delay(100)
|
delay(100)
|
||||||
)
|
)
|
||||||
@@ -357,16 +346,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
return objects.map((o) => o.id)
|
return objects.map((o) => o.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBulkEditFilters(): { [key: string]: any } {
|
|
||||||
const filters = { ...this.currentExtraParams }
|
|
||||||
if (this._nameFilter?.length) {
|
|
||||||
filters['name__icontains'] = this._nameFilter
|
|
||||||
}
|
|
||||||
return filters
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.allSelectionActive = false
|
|
||||||
this.togggleAll = false
|
this.togggleAll = false
|
||||||
this.selectedObjects.clear()
|
this.selectedObjects.clear()
|
||||||
}
|
}
|
||||||
@@ -376,7 +356,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectPage() {
|
selectPage() {
|
||||||
this.allSelectionActive = false
|
|
||||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||||
this.togggleAll = this.areAllPageItemsSelected()
|
this.togggleAll = this.areAllPageItemsSelected()
|
||||||
}
|
}
|
||||||
@@ -386,16 +365,11 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.selectedObjects = new Set(this.allIDs)
|
||||||
this.allSelectionActive = true
|
|
||||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
|
||||||
this.togggleAll = this.areAllPageItemsSelected()
|
this.togggleAll = this.areAllPageItemsSelected()
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSelected(object) {
|
toggleSelected(object) {
|
||||||
if (this.allSelectionActive) {
|
|
||||||
this.allSelectionActive = false
|
|
||||||
}
|
|
||||||
this.selectedObjects.has(object.id)
|
this.selectedObjects.has(object.id)
|
||||||
? this.selectedObjects.delete(object.id)
|
? this.selectedObjects.delete(object.id)
|
||||||
: this.selectedObjects.add(object.id)
|
: this.selectedObjects.add(object.id)
|
||||||
@@ -403,9 +377,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected areAllPageItemsSelected(): boolean {
|
protected areAllPageItemsSelected(): boolean {
|
||||||
if (this.allSelectionActive) {
|
|
||||||
return this.data.length > 0
|
|
||||||
}
|
|
||||||
const ids = this.getSelectableIDs(this.data)
|
const ids = this.getSelectableIDs(this.data)
|
||||||
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
|
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
|
||||||
}
|
}
|
||||||
@@ -419,12 +390,10 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.service
|
this.service
|
||||||
.bulk_edit_objects(
|
.bulk_edit_objects(
|
||||||
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
|
Array.from(this.selectedObjects),
|
||||||
BulkEditObjectOperation.SetPermissions,
|
BulkEditObjectOperation.SetPermissions,
|
||||||
permissions,
|
permissions,
|
||||||
merge,
|
merge
|
||||||
this.allSelectionActive,
|
|
||||||
this.allSelectionActive ? this.getBulkEditFilters() : null
|
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -459,12 +428,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.service
|
this.service
|
||||||
.bulk_edit_objects(
|
.bulk_edit_objects(
|
||||||
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
|
Array.from(this.selectedObjects),
|
||||||
BulkEditObjectOperation.Delete,
|
BulkEditObjectOperation.Delete
|
||||||
null,
|
|
||||||
null,
|
|
||||||
this.allSelectionActive,
|
|
||||||
this.allSelectionActive ? this.getBulkEditFilters() : null
|
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe('TagListComponent', () => {
|
|||||||
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
||||||
of({
|
of({
|
||||||
count: 3,
|
count: 3,
|
||||||
|
all: [1, 2, 3],
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
@@ -76,6 +77,16 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override getCollectionSize(results: Results<Tag>): number {
|
||||||
|
// Tag list pages are requested with is_root=true (when unfiltered), so
|
||||||
|
// pagination must follow root count even though `all` includes descendants
|
||||||
|
return results.count
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getDisplayCollectionSize(results: Results<Tag>): number {
|
||||||
|
return super.getCollectionSize(results)
|
||||||
|
}
|
||||||
|
|
||||||
protected override getSelectableIDs(tags: Tag[]): number[] {
|
protected override getSelectableIDs(tags: Tag[]): number[] {
|
||||||
const ids: number[] = []
|
const ids: number[] = []
|
||||||
for (const tag of tags.filter(Boolean)) {
|
for (const tag of tags.filter(Boolean)) {
|
||||||
|
|||||||
@@ -1,26 +1,7 @@
|
|||||||
import { Document } from './document'
|
|
||||||
|
|
||||||
export interface Results<T> {
|
export interface Results<T> {
|
||||||
count: number
|
count: number
|
||||||
|
|
||||||
display_count?: number
|
|
||||||
|
|
||||||
results: T[]
|
results: T[]
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectionDataItem {
|
all: number[]
|
||||||
id: number
|
|
||||||
document_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectionData {
|
|
||||||
selected_storage_paths: SelectionDataItem[]
|
|
||||||
selected_correspondents: SelectionDataItem[]
|
|
||||||
selected_tags: SelectionDataItem[]
|
|
||||||
selected_document_types: SelectionDataItem[]
|
|
||||||
selected_custom_fields: SelectionDataItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocumentResults extends Results<Document> {
|
|
||||||
selection_data?: SelectionData
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,10 +126,13 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.isReloading).toBeFalsy()
|
expect(documentListViewService.isReloading).toBeFalsy()
|
||||||
expect(documentListViewService.activeSavedViewId).toBeNull()
|
expect(documentListViewService.activeSavedViewId).toBeNull()
|
||||||
@@ -141,12 +144,12 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should handle error on page request out of range', () => {
|
it('should handle error on page request out of range', () => {
|
||||||
documentListViewService.currentPage = 50
|
documentListViewService.currentPage = 50
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush([], { status: 404, statusText: 'Unexpected error' })
|
req.flush([], { status: 404, statusText: 'Unexpected error' })
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
@@ -163,7 +166,7 @@ describe('DocumentListViewService', () => {
|
|||||||
]
|
]
|
||||||
documentListViewService.setFilterRules(filterRulesAny)
|
documentListViewService.setFilterRules(filterRulesAny)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(
|
req.flush(
|
||||||
@@ -171,13 +174,13 @@ describe('DocumentListViewService', () => {
|
|||||||
{ status: 404, statusText: 'Unexpected error' }
|
{ status: 404, statusText: 'Unexpected error' }
|
||||||
)
|
)
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,7 +188,7 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.currentPage = 1
|
documentListViewService.currentPage = 1
|
||||||
documentListViewService.sortField = 'custom_field_999'
|
documentListViewService.sortField = 'custom_field_999'
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(
|
req.flush(
|
||||||
@@ -194,7 +197,7 @@ describe('DocumentListViewService', () => {
|
|||||||
)
|
)
|
||||||
// resets itself
|
// resets itself
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,7 +212,7 @@ describe('DocumentListViewService', () => {
|
|||||||
]
|
]
|
||||||
documentListViewService.setFilterRules(filterRulesAny)
|
documentListViewService.setFilterRules(filterRulesAny)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
|
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
|
||||||
@@ -217,7 +220,7 @@ describe('DocumentListViewService', () => {
|
|||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,7 +229,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
documentListViewService.setSort('added', false)
|
documentListViewService.setSort('added', false)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.sortField).toEqual('added')
|
expect(documentListViewService.sortField).toEqual('added')
|
||||||
@@ -234,12 +237,12 @@ describe('DocumentListViewService', () => {
|
|||||||
|
|
||||||
documentListViewService.sortField = 'created'
|
documentListViewService.sortField = 'created'
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.sortField).toEqual('created')
|
expect(documentListViewService.sortField).toEqual('created')
|
||||||
documentListViewService.sortReverse = true
|
documentListViewService.sortReverse = true
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
@@ -259,7 +262,7 @@ describe('DocumentListViewService', () => {
|
|||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
|
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
|
||||||
documentListViewService.pageSize
|
documentListViewService.pageSize
|
||||||
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=true`
|
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.currentPage).toEqual(page)
|
expect(documentListViewService.currentPage).toEqual(page)
|
||||||
@@ -276,7 +279,7 @@ describe('DocumentListViewService', () => {
|
|||||||
}
|
}
|
||||||
documentListViewService.loadFromQueryParams(convertToParamMap(params))
|
documentListViewService.loadFromQueryParams(convertToParamMap(params))
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.filterRules).toEqual([
|
expect(documentListViewService.filterRules).toEqual([
|
||||||
@@ -286,12 +289,15 @@ describe('DocumentListViewService', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use filter rules to update query params', () => {
|
it('should use filter rules to update query params', () => {
|
||||||
documentListViewService.setFilterRules(filterRules)
|
documentListViewService.setFilterRules(filterRules)
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
@@ -300,26 +306,34 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.currentPage = 2
|
documentListViewService.currentPage = 2
|
||||||
let req = httpTestingController.expectOne((request) =>
|
let req = httpTestingController.expectOne((request) =>
|
||||||
request.urlWithParams.startsWith(
|
request.urlWithParams.startsWith(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
|
req.flush([])
|
||||||
|
|
||||||
documentListViewService.setFilterRules(filterRules, true)
|
documentListViewService.setFilterRules(filterRules, true)
|
||||||
|
|
||||||
const filteredReqs = httpTestingController.match(
|
const filteredReqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(filteredReqs).toHaveLength(1)
|
expect(filteredReqs).toHaveLength(1)
|
||||||
filteredReqs[0].flush(full_results)
|
filteredReqs[0].flush(full_results)
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
|
req.flush([])
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support quick filter', () => {
|
it('should support quick filter', () => {
|
||||||
documentListViewService.quickFilter(filterRules)
|
documentListViewService.quickFilter(filterRules)
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
@@ -342,21 +356,21 @@ describe('DocumentListViewService', () => {
|
|||||||
convertToParamMap(params)
|
convertToParamMap(params)
|
||||||
)
|
)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.currentPage = 1
|
documentListViewService.currentPage = 1
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true`
|
||||||
)
|
)
|
||||||
documentListViewService.sortField = 'created'
|
documentListViewService.sortField = 'created'
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
documentListViewService.activateSavedView(null)
|
documentListViewService.activateSavedView(null)
|
||||||
})
|
})
|
||||||
@@ -364,18 +378,21 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support navigating next / previous', () => {
|
it('should support navigating next / previous', () => {
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(`${environment.apiBaseUrl}documents/selection_data/`)
|
||||||
|
.flush([])
|
||||||
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
||||||
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
|
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
|
||||||
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
|
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
|
||||||
@@ -422,7 +439,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
jest
|
jest
|
||||||
.spyOn(documentListViewService, 'getLastPage')
|
.spyOn(documentListViewService, 'getLastPage')
|
||||||
@@ -437,7 +454,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect(documentListViewService.currentPage).toEqual(2)
|
expect(documentListViewService.currentPage).toEqual(2)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(reqs.length).toBeGreaterThan(0)
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -472,11 +489,11 @@ describe('DocumentListViewService', () => {
|
|||||||
.mockReturnValue(documents)
|
.mockReturnValue(documents)
|
||||||
documentListViewService.currentPage = 2
|
documentListViewService.currentPage = 2
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
|
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
|
||||||
documentListViewService.getPrevious(1).subscribe({
|
documentListViewService.getPrevious(1).subscribe({
|
||||||
@@ -486,7 +503,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(reqs.length).toBeGreaterThan(0)
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -499,10 +516,13 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select a document', () => {
|
it('should support select a document', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
@@ -524,13 +544,16 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select page', () => {
|
it('should support select page', () => {
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
documentListViewService.selectPage()
|
documentListViewService.selectPage()
|
||||||
expect(documentListViewService.selected.size).toEqual(3)
|
expect(documentListViewService.selected.size).toEqual(3)
|
||||||
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
|
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
|
||||||
@@ -539,10 +562,13 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select range', () => {
|
it('should support select range', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/selection_data/`
|
||||||
|
)
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.selectRangeTo(documents[2])
|
documentListViewService.selectRangeTo(documents[2])
|
||||||
@@ -562,7 +588,7 @@ describe('DocumentListViewService', () => {
|
|||||||
|
|
||||||
documentListViewService.setFilterRules(filterRules)
|
documentListViewService.setFilterRules(filterRules)
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id&tags__id__all=9`
|
||||||
@@ -578,7 +604,7 @@ describe('DocumentListViewService', () => {
|
|||||||
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
|
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
|
||||||
)
|
)
|
||||||
expect(cancelSpy).toHaveBeenCalled()
|
expect(cancelSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -597,7 +623,7 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
expect(documentListViewService.sortField).toEqual('created')
|
expect(documentListViewService.sortField).toEqual('created')
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -624,11 +650,11 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(localStorageSpy).toHaveBeenCalled()
|
expect(localStorageSpy).toHaveBeenCalled()
|
||||||
// reload triggered
|
// reload triggered
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
documentListViewService.displayFields = null
|
documentListViewService.displayFields = null
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.displayFields).toEqual(
|
expect(documentListViewService.displayFields).toEqual(
|
||||||
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
|
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
|
||||||
@@ -668,7 +694,7 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should generate quick filter URL preserving default state', () => {
|
it('should generate quick filter URL preserving default state', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
)
|
)
|
||||||
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
||||||
expect(urlTree).toBeDefined()
|
expect(urlTree).toBeDefined()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core'
|
import { Injectable, inject } from '@angular/core'
|
||||||
import { ParamMap, Router, UrlTree } from '@angular/router'
|
import { ParamMap, Router, UrlTree } from '@angular/router'
|
||||||
import { Observable, Subject, takeUntil } from 'rxjs'
|
import { Observable, Subject, first, takeUntil } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
DEFAULT_DISPLAY_FIELDS,
|
DEFAULT_DISPLAY_FIELDS,
|
||||||
DisplayField,
|
DisplayField,
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Document,
|
Document,
|
||||||
} from '../data/document'
|
} from '../data/document'
|
||||||
import { FilterRule } from '../data/filter-rule'
|
import { FilterRule } from '../data/filter-rule'
|
||||||
import { DocumentResults, SelectionData } from '../data/results'
|
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
|
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
|
||||||
import { SETTINGS_KEYS } from '../data/ui-settings'
|
import { SETTINGS_KEYS } from '../data/ui-settings'
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
isFullTextFilterRule,
|
isFullTextFilterRule,
|
||||||
} from '../utils/filter-rules'
|
} from '../utils/filter-rules'
|
||||||
import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
|
import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
|
||||||
import { DocumentService } from './rest/document.service'
|
import { DocumentService, SelectionData } from './rest/document.service'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
|
|
||||||
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
|
const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map(
|
||||||
@@ -261,17 +260,27 @@ export class DocumentListViewService {
|
|||||||
activeListViewState.sortField,
|
activeListViewState.sortField,
|
||||||
activeListViewState.sortReverse,
|
activeListViewState.sortReverse,
|
||||||
activeListViewState.filterRules,
|
activeListViewState.filterRules,
|
||||||
{ truncate_content: true, include_selection_data: true }
|
{ truncate_content: true }
|
||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
const resultWithSelectionData = result as DocumentResults
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
this.isReloading = false
|
this.isReloading = false
|
||||||
activeListViewState.collectionSize = result.count
|
activeListViewState.collectionSize = result.count
|
||||||
activeListViewState.documents = result.results
|
activeListViewState.documents = result.results
|
||||||
this.selectionData = resultWithSelectionData.selection_data ?? null
|
|
||||||
|
this.documentService
|
||||||
|
.getSelectionData(result.all)
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe({
|
||||||
|
next: (selectionData) => {
|
||||||
|
this.selectionData = selectionData
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.selectionData = null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (updateQueryParams && !this._activeSavedViewId) {
|
if (updateQueryParams && !this._activeSavedViewId) {
|
||||||
let base = ['/documents']
|
let base = ['/documents']
|
||||||
|
|||||||
@@ -96,30 +96,6 @@ export const commonAbstractNameFilterPaperlessServiceTests = (
|
|||||||
})
|
})
|
||||||
req.flush([])
|
req.flush([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should call appropriate api endpoint for bulk delete on all filtered objects', () => {
|
|
||||||
subscription = service
|
|
||||||
.bulk_edit_objects(
|
|
||||||
[],
|
|
||||||
BulkEditObjectOperation.Delete,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
{ name__icontains: 'hello' }
|
|
||||||
)
|
|
||||||
.subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}bulk_edit_objects/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
object_type: endpoint,
|
|
||||||
operation: BulkEditObjectOperation.Delete,
|
|
||||||
all: true,
|
|
||||||
filters: { name__icontains: 'hello' },
|
|
||||||
})
|
|
||||||
req.flush([])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -37,22 +37,13 @@ export abstract class AbstractNameFilterService<
|
|||||||
objects: Array<number>,
|
objects: Array<number>,
|
||||||
operation: BulkEditObjectOperation,
|
operation: BulkEditObjectOperation,
|
||||||
permissions: { owner: number; set_permissions: PermissionsObject } = null,
|
permissions: { owner: number; set_permissions: PermissionsObject } = null,
|
||||||
merge: boolean = null,
|
merge: boolean = null
|
||||||
all: boolean = false,
|
|
||||||
filters: { [key: string]: any } = null
|
|
||||||
): Observable<string> {
|
): Observable<string> {
|
||||||
const params: any = {
|
const params = {
|
||||||
|
objects,
|
||||||
object_type: this.resourceName,
|
object_type: this.resourceName,
|
||||||
operation,
|
operation,
|
||||||
}
|
}
|
||||||
if (all) {
|
|
||||||
params['all'] = true
|
|
||||||
if (filters) {
|
|
||||||
params['filters'] = filters
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
params['objects'] = objects
|
|
||||||
}
|
|
||||||
if (operation === BulkEditObjectOperation.SetPermissions) {
|
if (operation === BulkEditObjectOperation.SetPermissions) {
|
||||||
params['owner'] = permissions?.owner
|
params['owner'] = permissions?.owner
|
||||||
params['permissions'] = permissions?.set_permissions
|
params['permissions'] = permissions?.set_permissions
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||||
import { FilterRule } from 'src/app/data/filter-rule'
|
import { FilterRule } from 'src/app/data/filter-rule'
|
||||||
import { Results, SelectionData } from 'src/app/data/results'
|
import { Results } from 'src/app/data/results'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,19 @@ import { SettingsService } from '../settings.service'
|
|||||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||||
import { CustomFieldsService } from './custom-fields.service'
|
import { CustomFieldsService } from './custom-fields.service'
|
||||||
|
|
||||||
|
export interface SelectionDataItem {
|
||||||
|
id: number
|
||||||
|
document_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionData {
|
||||||
|
selected_storage_paths: SelectionDataItem[]
|
||||||
|
selected_correspondents: SelectionDataItem[]
|
||||||
|
selected_tags: SelectionDataItem[]
|
||||||
|
selected_document_types: SelectionDataItem[]
|
||||||
|
selected_custom_fields: SelectionDataItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export enum BulkEditSourceMode {
|
export enum BulkEditSourceMode {
|
||||||
LATEST_VERSION = 'latest_version',
|
LATEST_VERSION = 'latest_version',
|
||||||
EXPLICIT_SELECTION = 'explicit_selection',
|
EXPLICIT_SELECTION = 'explicit_selection',
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from documents.templating.workflows import parse_w_workflow_placeholders
|
|||||||
from documents.utils import copy_basic_file_stats
|
from documents.utils import copy_basic_file_stats
|
||||||
from documents.utils import copy_file_with_basic_stats
|
from documents.utils import copy_file_with_basic_stats
|
||||||
from documents.utils import run_subprocess
|
from documents.utils import run_subprocess
|
||||||
|
from paperless.parsers.remote import RemoteDocumentParser
|
||||||
from paperless.parsers.text import TextDocumentParser
|
from paperless.parsers.text import TextDocumentParser
|
||||||
from paperless_mail.parsers import MailDocumentParser
|
from paperless_mail.parsers import MailDocumentParser
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ def _parser_cleanup(parser: DocumentParser) -> None:
|
|||||||
|
|
||||||
TODO(stumpylog): Remove me in the future
|
TODO(stumpylog): Remove me in the future
|
||||||
"""
|
"""
|
||||||
if isinstance(parser, TextDocumentParser):
|
if isinstance(parser, (TextDocumentParser, RemoteDocumentParser)):
|
||||||
parser.__exit__(None, None, None)
|
parser.__exit__(None, None, None)
|
||||||
else:
|
else:
|
||||||
parser.cleanup()
|
parser.cleanup()
|
||||||
@@ -476,7 +477,10 @@ class ConsumerPlugin(
|
|||||||
self.filename,
|
self.filename,
|
||||||
self.input_doc.mailrule_id,
|
self.input_doc.mailrule_id,
|
||||||
)
|
)
|
||||||
elif isinstance(document_parser, TextDocumentParser):
|
elif isinstance(
|
||||||
|
document_parser,
|
||||||
|
(TextDocumentParser, RemoteDocumentParser),
|
||||||
|
):
|
||||||
# TODO(stumpylog): Remove me in the future
|
# TODO(stumpylog): Remove me in the future
|
||||||
document_parser.parse(self.working_copy, mime_type)
|
document_parser.parse(self.working_copy, mime_type)
|
||||||
else:
|
else:
|
||||||
@@ -489,7 +493,7 @@ class ConsumerPlugin(
|
|||||||
ProgressStatusOptions.WORKING,
|
ProgressStatusOptions.WORKING,
|
||||||
ConsumerStatusShortMessage.GENERATING_THUMBNAIL,
|
ConsumerStatusShortMessage.GENERATING_THUMBNAIL,
|
||||||
)
|
)
|
||||||
if isinstance(document_parser, TextDocumentParser):
|
if isinstance(document_parser, (TextDocumentParser, RemoteDocumentParser)):
|
||||||
# TODO(stumpylog): Remove me in the future
|
# TODO(stumpylog): Remove me in the future
|
||||||
thumbnail = document_parser.get_thumbnail(self.working_copy, mime_type)
|
thumbnail = document_parser.get_thumbnail(self.working_copy, mime_type)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -375,26 +375,6 @@ class DelayedQuery:
|
|||||||
]
|
]
|
||||||
return self._manual_hits_cache
|
return self._manual_hits_cache
|
||||||
|
|
||||||
def get_result_ids(self) -> list[int]:
|
|
||||||
"""
|
|
||||||
Return all matching document IDs for the current query and ordering.
|
|
||||||
"""
|
|
||||||
if self._manual_sort_requested():
|
|
||||||
return [hit["id"] for hit in self._manual_hits()]
|
|
||||||
|
|
||||||
q, mask, suggested_correction = self._get_query()
|
|
||||||
self.suggested_correction = suggested_correction
|
|
||||||
sortedby, reverse = self._get_query_sortedby()
|
|
||||||
results = self.searcher.search(
|
|
||||||
q,
|
|
||||||
mask=mask,
|
|
||||||
filter=MappedDocIdSet(self.filter_queryset, self.searcher.ixreader),
|
|
||||||
limit=None,
|
|
||||||
sortedby=sortedby,
|
|
||||||
reverse=reverse,
|
|
||||||
)
|
|
||||||
return [hit["id"] for hit in results]
|
|
||||||
|
|
||||||
def __getitem__(self, item):
|
def __getitem__(self, item):
|
||||||
if item.start in self.saved_results:
|
if item.start in self.saved_results:
|
||||||
return self.saved_results[item.start]
|
return self.saved_results[item.start]
|
||||||
|
|||||||
@@ -1540,41 +1540,6 @@ class DocumentListSerializer(serializers.Serializer):
|
|||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
|
||||||
class DocumentSelectionSerializer(DocumentListSerializer):
|
|
||||||
documents = serializers.ListField(
|
|
||||||
required=False,
|
|
||||||
label="Documents",
|
|
||||||
write_only=True,
|
|
||||||
child=serializers.IntegerField(),
|
|
||||||
)
|
|
||||||
|
|
||||||
all = serializers.BooleanField(
|
|
||||||
default=False,
|
|
||||||
required=False,
|
|
||||||
write_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
filters = serializers.DictField(
|
|
||||||
required=False,
|
|
||||||
allow_empty=True,
|
|
||||||
write_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
if attrs.get("all", False):
|
|
||||||
attrs.setdefault("documents", [])
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
if "documents" not in attrs:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"documents is required unless all is true.",
|
|
||||||
)
|
|
||||||
|
|
||||||
documents = attrs["documents"]
|
|
||||||
self._validate_document_id_list(documents)
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class SourceModeValidationMixin:
|
class SourceModeValidationMixin:
|
||||||
def validate_source_mode(self, source_mode: str) -> str:
|
def validate_source_mode(self, source_mode: str) -> str:
|
||||||
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
|
if source_mode not in bulk_edit.SourceModeChoices.__dict__.values():
|
||||||
@@ -1582,7 +1547,7 @@ class SourceModeValidationMixin:
|
|||||||
return source_mode
|
return source_mode
|
||||||
|
|
||||||
|
|
||||||
class RotateDocumentsSerializer(DocumentSelectionSerializer, SourceModeValidationMixin):
|
class RotateDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
||||||
degrees = serializers.IntegerField(required=True)
|
degrees = serializers.IntegerField(required=True)
|
||||||
source_mode = serializers.CharField(
|
source_mode = serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
@@ -1665,17 +1630,17 @@ class RemovePasswordDocumentsSerializer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
class DeleteDocumentsSerializer(DocumentListSerializer):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
class ReprocessDocumentsSerializer(DocumentListSerializer):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BulkEditSerializer(
|
class BulkEditSerializer(
|
||||||
SerializerWithPerms,
|
SerializerWithPerms,
|
||||||
DocumentSelectionSerializer,
|
DocumentListSerializer,
|
||||||
SetPermissionsMixin,
|
SetPermissionsMixin,
|
||||||
SourceModeValidationMixin,
|
SourceModeValidationMixin,
|
||||||
):
|
):
|
||||||
@@ -1990,19 +1955,6 @@ class BulkEditSerializer(
|
|||||||
raise serializers.ValidationError("password must be a string")
|
raise serializers.ValidationError("password must be a string")
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
attrs = super().validate(attrs)
|
|
||||||
|
|
||||||
if attrs.get("all", False) and attrs["method"] in [
|
|
||||||
bulk_edit.merge,
|
|
||||||
bulk_edit.split,
|
|
||||||
bulk_edit.delete_pages,
|
|
||||||
bulk_edit.edit_pdf,
|
|
||||||
bulk_edit.remove_password,
|
|
||||||
]:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"This method does not support all=true.",
|
|
||||||
)
|
|
||||||
|
|
||||||
method = attrs["method"]
|
method = attrs["method"]
|
||||||
parameters = attrs["parameters"]
|
parameters = attrs["parameters"]
|
||||||
|
|
||||||
@@ -2260,7 +2212,7 @@ class DocumentVersionLabelSerializer(serializers.Serializer):
|
|||||||
return normalized or None
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
class BulkDownloadSerializer(DocumentSelectionSerializer):
|
class BulkDownloadSerializer(DocumentListSerializer):
|
||||||
content = serializers.ChoiceField(
|
content = serializers.ChoiceField(
|
||||||
choices=["archive", "originals", "both"],
|
choices=["archive", "originals", "both"],
|
||||||
default="archive",
|
default="archive",
|
||||||
@@ -2619,25 +2571,13 @@ class ShareLinkBundleSerializer(OwnedObjectSerializer):
|
|||||||
|
|
||||||
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||||
objects = serializers.ListField(
|
objects = serializers.ListField(
|
||||||
required=False,
|
required=True,
|
||||||
allow_empty=True,
|
allow_empty=False,
|
||||||
label="Objects",
|
label="Objects",
|
||||||
write_only=True,
|
write_only=True,
|
||||||
child=serializers.IntegerField(),
|
child=serializers.IntegerField(),
|
||||||
)
|
)
|
||||||
|
|
||||||
all = serializers.BooleanField(
|
|
||||||
default=False,
|
|
||||||
required=False,
|
|
||||||
write_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
filters = serializers.DictField(
|
|
||||||
required=False,
|
|
||||||
allow_empty=True,
|
|
||||||
write_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
object_type = serializers.ChoiceField(
|
object_type = serializers.ChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
"tags",
|
"tags",
|
||||||
@@ -2710,20 +2650,10 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
|||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
object_type = attrs["object_type"]
|
object_type = attrs["object_type"]
|
||||||
objects = attrs.get("objects")
|
objects = attrs["objects"]
|
||||||
apply_to_all = attrs.get("all", False)
|
|
||||||
operation = attrs.get("operation")
|
operation = attrs.get("operation")
|
||||||
|
|
||||||
if apply_to_all:
|
self._validate_objects(objects, object_type)
|
||||||
attrs.setdefault("objects", [])
|
|
||||||
else:
|
|
||||||
if objects is None:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"objects is required unless all is true.",
|
|
||||||
)
|
|
||||||
if len(objects) == 0:
|
|
||||||
raise serializers.ValidationError("objects must not be empty")
|
|
||||||
self._validate_objects(objects, object_type)
|
|
||||||
|
|
||||||
if operation == "set_permissions":
|
if operation == "set_permissions":
|
||||||
permissions = attrs.get("permissions")
|
permissions = attrs.get("permissions")
|
||||||
|
|||||||
@@ -1119,19 +1119,21 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
[u1_doc1.id],
|
[u1_doc1.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_pagination_results(self) -> None:
|
def test_pagination_all(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A set of 50 documents
|
- A set of 50 documents
|
||||||
WHEN:
|
WHEN:
|
||||||
- API request for document filtering
|
- API request for document filtering
|
||||||
THEN:
|
THEN:
|
||||||
- Results are paginated (25 items) and count reflects all results (50 items)
|
- Results are paginated (25 items) and response["all"] returns all ids (50 items)
|
||||||
"""
|
"""
|
||||||
t = Tag.objects.create(name="tag")
|
t = Tag.objects.create(name="tag")
|
||||||
|
docs = []
|
||||||
for i in range(50):
|
for i in range(50):
|
||||||
d = Document.objects.create(checksum=i, content=f"test{i}")
|
d = Document.objects.create(checksum=i, content=f"test{i}")
|
||||||
d.tags.add(t)
|
d.tags.add(t)
|
||||||
|
docs.append(d)
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
f"/api/documents/?tags__id__in={t.id}",
|
f"/api/documents/?tags__id__in={t.id}",
|
||||||
@@ -1139,84 +1141,9 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(len(results), 25)
|
self.assertEqual(len(results), 25)
|
||||||
self.assertEqual(response.data["count"], 50)
|
self.assertEqual(len(response.data["all"]), 50)
|
||||||
self.assertNotIn("all", response.data)
|
|
||||||
|
|
||||||
def test_pagination_all_for_api_version_9(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A set of documents matching a filter
|
|
||||||
WHEN:
|
|
||||||
- API request uses legacy version 9
|
|
||||||
THEN:
|
|
||||||
- Response includes "all" for backward compatibility
|
|
||||||
"""
|
|
||||||
t = Tag.objects.create(name="tag")
|
|
||||||
docs = []
|
|
||||||
for i in range(4):
|
|
||||||
d = Document.objects.create(checksum=i, content=f"test{i}")
|
|
||||||
d.tags.add(t)
|
|
||||||
docs.append(d)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/?tags__id__in={t.id}",
|
|
||||||
headers={"Accept": "application/json; version=9"},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertIn("all", response.data)
|
|
||||||
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
self.assertCountEqual(response.data["all"], [d.id for d in docs])
|
||||||
|
|
||||||
def test_list_with_include_selection_data(self) -> None:
|
|
||||||
correspondent = Correspondent.objects.create(name="c1")
|
|
||||||
doc_type = DocumentType.objects.create(name="dt1")
|
|
||||||
storage_path = StoragePath.objects.create(name="sp1")
|
|
||||||
tag = Tag.objects.create(name="tag")
|
|
||||||
|
|
||||||
matching_doc = Document.objects.create(
|
|
||||||
checksum="A",
|
|
||||||
correspondent=correspondent,
|
|
||||||
document_type=doc_type,
|
|
||||||
storage_path=storage_path,
|
|
||||||
)
|
|
||||||
matching_doc.tags.add(tag)
|
|
||||||
|
|
||||||
non_matching_doc = Document.objects.create(checksum="B")
|
|
||||||
non_matching_doc.tags.add(Tag.objects.create(name="other"))
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/?tags__id__in={tag.id}&include_selection_data=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertIn("selection_data", response.data)
|
|
||||||
|
|
||||||
selected_correspondent = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_correspondents"]
|
|
||||||
if item["id"] == correspondent.id
|
|
||||||
)
|
|
||||||
selected_tag = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_tags"]
|
|
||||||
if item["id"] == tag.id
|
|
||||||
)
|
|
||||||
selected_type = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_document_types"]
|
|
||||||
if item["id"] == doc_type.id
|
|
||||||
)
|
|
||||||
selected_storage_path = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_storage_paths"]
|
|
||||||
if item["id"] == storage_path.id
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(selected_correspondent["document_count"], 1)
|
|
||||||
self.assertEqual(selected_tag["document_count"], 1)
|
|
||||||
self.assertEqual(selected_type["document_count"], 1)
|
|
||||||
self.assertEqual(selected_storage_path["document_count"], 1)
|
|
||||||
|
|
||||||
def test_statistics(self) -> None:
|
def test_statistics(self) -> None:
|
||||||
doc1 = Document.objects.create(
|
doc1 = Document.objects.create(
|
||||||
title="none1",
|
title="none1",
|
||||||
|
|||||||
@@ -145,22 +145,6 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
response.data["last_correspondence"],
|
response.data["last_correspondence"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
|
|
||||||
response_v10 = self.client.get("/api/correspondents/")
|
|
||||||
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertNotIn("all", response_v10.data)
|
|
||||||
|
|
||||||
response_v9 = self.client.get(
|
|
||||||
"/api/correspondents/",
|
|
||||||
headers={"Accept": "application/json; version=9"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response_v9.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertIn("all", response_v9.data)
|
|
||||||
self.assertCountEqual(
|
|
||||||
response_v9.data["all"],
|
|
||||||
[self.c1.id, self.c2.id, self.c3.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||||
ENDPOINT = "/api/storage_paths/"
|
ENDPOINT = "/api/storage_paths/"
|
||||||
@@ -790,62 +774,6 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(StoragePath.objects.count(), 0)
|
self.assertEqual(StoragePath.objects.count(), 0)
|
||||||
|
|
||||||
def test_bulk_objects_delete_all_filtered(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing objects that can be filtered by name
|
|
||||||
WHEN:
|
|
||||||
- bulk_edit_objects API endpoint is called with all=true and filters
|
|
||||||
THEN:
|
|
||||||
- Matching objects are deleted without passing explicit IDs
|
|
||||||
"""
|
|
||||||
Correspondent.objects.create(name="c2")
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/bulk_edit_objects/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"all": True,
|
|
||||||
"filters": {"name__icontains": "c"},
|
|
||||||
"object_type": "correspondents",
|
|
||||||
"operation": "delete",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(Correspondent.objects.count(), 0)
|
|
||||||
|
|
||||||
def test_bulk_objects_delete_all_filtered_tags_includes_descendants(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Root tag with descendants
|
|
||||||
WHEN:
|
|
||||||
- bulk_edit_objects API endpoint is called with all=true
|
|
||||||
THEN:
|
|
||||||
- Root tags and descendants are deleted
|
|
||||||
"""
|
|
||||||
parent = Tag.objects.create(name="parent")
|
|
||||||
child = Tag.objects.create(name="child", tn_parent=parent)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/bulk_edit_objects/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"all": True,
|
|
||||||
"filters": {"is_root": True},
|
|
||||||
"object_type": "tags",
|
|
||||||
"operation": "delete",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertFalse(Tag.objects.filter(id=parent.id).exists())
|
|
||||||
self.assertFalse(Tag.objects.filter(id=child.id).exists())
|
|
||||||
|
|
||||||
def test_bulk_edit_object_permissions_insufficient_global_perms(self) -> None:
|
def test_bulk_edit_object_permissions_insufficient_global_perms(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -933,40 +861,3 @@ class TestBulkEditObjects(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_all_filtered_permissions_insufficient_object_perms(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Filter-matching objects include one that the user cannot edit
|
|
||||||
WHEN:
|
|
||||||
- bulk_edit_objects API endpoint is called with all=true
|
|
||||||
THEN:
|
|
||||||
- Operation applies only to editable objects
|
|
||||||
"""
|
|
||||||
self.t2.owner = User.objects.get(username="temp_admin")
|
|
||||||
self.t2.save()
|
|
||||||
|
|
||||||
self.user1.user_permissions.add(
|
|
||||||
*Permission.objects.filter(codename="delete_tag"),
|
|
||||||
)
|
|
||||||
self.user1.save()
|
|
||||||
self.client.force_authenticate(user=self.user1)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/bulk_edit_objects/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"all": True,
|
|
||||||
"filters": {"name__icontains": "t"},
|
|
||||||
"object_type": "tags",
|
|
||||||
"operation": "delete",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertTrue(Tag.objects.filter(id=self.t2.id).exists())
|
|
||||||
self.assertFalse(Tag.objects.filter(id=self.t1.id).exists())
|
|
||||||
|
|||||||
@@ -68,88 +68,26 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(response.data["count"], 3)
|
self.assertEqual(response.data["count"], 3)
|
||||||
self.assertEqual(len(results), 3)
|
self.assertEqual(len(results), 3)
|
||||||
|
self.assertCountEqual(response.data["all"], [d1.id, d2.id, d3.id])
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=september")
|
response = self.client.get("/api/documents/?query=september")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(response.data["count"], 1)
|
self.assertEqual(response.data["count"], 1)
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertCountEqual(response.data["all"], [d3.id])
|
||||||
self.assertEqual(results[0]["original_file_name"], "someepdf.pdf")
|
self.assertEqual(results[0]["original_file_name"], "someepdf.pdf")
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=statement")
|
response = self.client.get("/api/documents/?query=statement")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(response.data["count"], 2)
|
self.assertEqual(response.data["count"], 2)
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
|
self.assertCountEqual(response.data["all"], [d2.id, d3.id])
|
||||||
|
|
||||||
response = self.client.get("/api/documents/?query=sfegdfg")
|
response = self.client.get("/api/documents/?query=sfegdfg")
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(response.data["count"], 0)
|
self.assertEqual(response.data["count"], 0)
|
||||||
self.assertEqual(len(results), 0)
|
self.assertEqual(len(results), 0)
|
||||||
|
self.assertCountEqual(response.data["all"], [])
|
||||||
def test_search_returns_all_for_api_version_9(self) -> None:
|
|
||||||
d1 = Document.objects.create(
|
|
||||||
title="invoice",
|
|
||||||
content="bank payment",
|
|
||||||
checksum="A",
|
|
||||||
pk=1,
|
|
||||||
)
|
|
||||||
d2 = Document.objects.create(
|
|
||||||
title="bank statement",
|
|
||||||
content="bank transfer",
|
|
||||||
checksum="B",
|
|
||||||
pk=2,
|
|
||||||
)
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
|
||||||
index.update_document(writer, d1)
|
|
||||||
index.update_document(writer, d2)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
"/api/documents/?query=bank",
|
|
||||||
headers={"Accept": "application/json; version=9"},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertIn("all", response.data)
|
|
||||||
self.assertCountEqual(response.data["all"], [d1.id, d2.id])
|
|
||||||
|
|
||||||
def test_search_with_include_selection_data(self) -> None:
|
|
||||||
correspondent = Correspondent.objects.create(name="c1")
|
|
||||||
doc_type = DocumentType.objects.create(name="dt1")
|
|
||||||
storage_path = StoragePath.objects.create(name="sp1")
|
|
||||||
tag = Tag.objects.create(name="tag")
|
|
||||||
|
|
||||||
matching_doc = Document.objects.create(
|
|
||||||
title="bank statement",
|
|
||||||
content="bank content",
|
|
||||||
checksum="A",
|
|
||||||
correspondent=correspondent,
|
|
||||||
document_type=doc_type,
|
|
||||||
storage_path=storage_path,
|
|
||||||
)
|
|
||||||
matching_doc.tags.add(tag)
|
|
||||||
|
|
||||||
with AsyncWriter(index.open_index()) as writer:
|
|
||||||
index.update_document(writer, matching_doc)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
"/api/documents/?query=bank&include_selection_data=true",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertIn("selection_data", response.data)
|
|
||||||
|
|
||||||
selected_correspondent = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_correspondents"]
|
|
||||||
if item["id"] == correspondent.id
|
|
||||||
)
|
|
||||||
selected_tag = next(
|
|
||||||
item
|
|
||||||
for item in response.data["selection_data"]["selected_tags"]
|
|
||||||
if item["id"] == tag.id
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(selected_correspondent["document_count"], 1)
|
|
||||||
self.assertEqual(selected_tag["document_count"], 1)
|
|
||||||
|
|
||||||
def test_search_custom_field_ordering(self) -> None:
|
def test_search_custom_field_ordering(self) -> None:
|
||||||
custom_field = CustomField.objects.create(
|
custom_field = CustomField.objects.create(
|
||||||
|
|||||||
@@ -555,9 +555,7 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
|||||||
page = self.paginate_queryset(queryset)
|
page = self.paginate_queryset(queryset)
|
||||||
serializer = self.get_serializer(page, many=True)
|
serializer = self.get_serializer(page, many=True)
|
||||||
response = self.get_paginated_response(serializer.data)
|
response = self.get_paginated_response(serializer.data)
|
||||||
response.data["display_count"] = len(children_source)
|
if descendant_pks:
|
||||||
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
|
|
||||||
if descendant_pks and api_version < 10:
|
|
||||||
# Include children in the "all" field, if needed
|
# Include children in the "all" field, if needed
|
||||||
response.data["all"] = [tag.pk for tag in children_source]
|
response.data["all"] = [tag.pk for tag in children_source]
|
||||||
return response
|
return response
|
||||||
@@ -837,61 +835,6 @@ class DocumentViewSet(
|
|||||||
"custom_field_",
|
"custom_field_",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_selection_data_for_queryset(self, queryset):
|
|
||||||
correspondents = Correspondent.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
tags = Tag.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
document_types = DocumentType.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
storage_paths = StoragePath.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"documents",
|
|
||||||
filter=Q(documents__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
custom_fields = CustomField.objects.annotate(
|
|
||||||
document_count=Count(
|
|
||||||
"fields__document",
|
|
||||||
filter=Q(fields__document__in=queryset),
|
|
||||||
distinct=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"selected_correspondents": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in correspondents
|
|
||||||
],
|
|
||||||
"selected_tags": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in tags
|
|
||||||
],
|
|
||||||
"selected_document_types": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in document_types
|
|
||||||
],
|
|
||||||
"selected_storage_paths": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in storage_paths
|
|
||||||
],
|
|
||||||
"selected_custom_fields": [
|
|
||||||
{"id": t.id, "document_count": t.document_count} for t in custom_fields
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
latest_version_content = Subquery(
|
latest_version_content = Subquery(
|
||||||
Document.objects.filter(root_document=OuterRef("pk"))
|
Document.objects.filter(root_document=OuterRef("pk"))
|
||||||
@@ -1039,25 +982,6 @@ class DocumentViewSet(
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
|
||||||
if not get_boolean(
|
|
||||||
str(request.query_params.get("include_selection_data", "false")),
|
|
||||||
):
|
|
||||||
return super().list(request, *args, **kwargs)
|
|
||||||
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
|
||||||
selection_data = self._get_selection_data_for_queryset(queryset)
|
|
||||||
|
|
||||||
page = self.paginate_queryset(queryset)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True)
|
|
||||||
response = self.get_paginated_response(serializer.data)
|
|
||||||
response.data["selection_data"] = selection_data
|
|
||||||
return response
|
|
||||||
|
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
|
||||||
return Response({"results": serializer.data, "selection_data": selection_data})
|
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
from documents import index
|
from documents import index
|
||||||
|
|
||||||
@@ -2078,21 +2002,6 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
if get_boolean(
|
|
||||||
str(
|
|
||||||
request.query_params.get(
|
|
||||||
"include_selection_data",
|
|
||||||
"false",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
result_ids = queryset.get_result_ids()
|
|
||||||
response.data["selection_data"] = (
|
|
||||||
self._get_selection_data_for_queryset(
|
|
||||||
Document.objects.filter(pk__in=result_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
except NotFound:
|
except NotFound:
|
||||||
raise
|
raise
|
||||||
@@ -2215,36 +2124,7 @@ class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet):
|
|||||||
ordering_fields = ("name",)
|
ordering_fields = ("name",)
|
||||||
|
|
||||||
|
|
||||||
class DocumentSelectionMixin:
|
class DocumentOperationPermissionMixin(PassUserMixin):
|
||||||
def _resolve_document_ids(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
user: User,
|
|
||||||
validated_data: dict[str, Any],
|
|
||||||
permission_codename: str = "view_document",
|
|
||||||
) -> list[int]:
|
|
||||||
if not validated_data.get("all", False):
|
|
||||||
# if all is not true, just pass through the provided document ids
|
|
||||||
return validated_data["documents"]
|
|
||||||
|
|
||||||
# otherwise, reconstruct the document list based on the provided filters
|
|
||||||
filters = validated_data.get("filters") or {}
|
|
||||||
permitted_documents = get_objects_for_user_owner_aware(
|
|
||||||
user,
|
|
||||||
permission_codename,
|
|
||||||
Document,
|
|
||||||
)
|
|
||||||
return list(
|
|
||||||
DocumentFilterSet(
|
|
||||||
data=filters,
|
|
||||||
queryset=permitted_documents,
|
|
||||||
)
|
|
||||||
.qs.distinct()
|
|
||||||
.values_list("pk", flat=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
parser_classes = (parsers.JSONParser,)
|
parser_classes = (parsers.JSONParser,)
|
||||||
METHOD_NAMES_REQUIRING_USER = {
|
METHOD_NAMES_REQUIRING_USER = {
|
||||||
@@ -2338,15 +2218,8 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
|||||||
validated_data: dict[str, Any],
|
validated_data: dict[str, Any],
|
||||||
operation_label: str,
|
operation_label: str,
|
||||||
):
|
):
|
||||||
documents = self._resolve_document_ids(
|
documents = validated_data["documents"]
|
||||||
user=self.request.user,
|
parameters = {k: v for k, v in validated_data.items() if k != "documents"}
|
||||||
validated_data=validated_data,
|
|
||||||
)
|
|
||||||
parameters = {
|
|
||||||
k: v
|
|
||||||
for k, v in validated_data.items()
|
|
||||||
if k not in {"documents", "all", "filters"}
|
|
||||||
}
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
|
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
|
||||||
@@ -2434,10 +2307,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
|||||||
user = self.request.user
|
user = self.request.user
|
||||||
method = serializer.validated_data.get("method")
|
method = serializer.validated_data.get("method")
|
||||||
parameters = serializer.validated_data.get("parameters")
|
parameters = serializer.validated_data.get("parameters")
|
||||||
documents = self._resolve_document_ids(
|
documents = serializer.validated_data.get("documents")
|
||||||
user=user,
|
|
||||||
validated_data=serializer.validated_data,
|
|
||||||
)
|
|
||||||
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
|
if method.__name__ in self.METHOD_NAMES_REQUIRING_USER:
|
||||||
parameters["user"] = user
|
parameters["user"] = user
|
||||||
if not self._has_document_permissions(
|
if not self._has_document_permissions(
|
||||||
@@ -3281,7 +3151,7 @@ class StatisticsView(GenericAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView):
|
class BulkDownloadView(GenericAPIView):
|
||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
serializer_class = BulkDownloadSerializer
|
serializer_class = BulkDownloadSerializer
|
||||||
parser_classes = (parsers.JSONParser,)
|
parser_classes = (parsers.JSONParser,)
|
||||||
@@ -3290,10 +3160,7 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView):
|
|||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
ids = self._resolve_document_ids(
|
ids = serializer.validated_data.get("documents")
|
||||||
user=request.user,
|
|
||||||
validated_data=serializer.validated_data,
|
|
||||||
)
|
|
||||||
documents = Document.objects.filter(pk__in=ids)
|
documents = Document.objects.filter(pk__in=ids)
|
||||||
compression = serializer.validated_data.get("compression")
|
compression = serializer.validated_data.get("compression")
|
||||||
content = serializer.validated_data.get("content")
|
content = serializer.validated_data.get("content")
|
||||||
@@ -3923,55 +3790,20 @@ class BulkEditObjectsView(PassUserMixin):
|
|||||||
user = self.request.user
|
user = self.request.user
|
||||||
object_type = serializer.validated_data.get("object_type")
|
object_type = serializer.validated_data.get("object_type")
|
||||||
object_ids = serializer.validated_data.get("objects")
|
object_ids = serializer.validated_data.get("objects")
|
||||||
apply_to_all = serializer.validated_data.get("all")
|
|
||||||
object_class = serializer.get_object_class(object_type)
|
object_class = serializer.get_object_class(object_type)
|
||||||
operation = serializer.validated_data.get("operation")
|
operation = serializer.validated_data.get("operation")
|
||||||
model_name = object_class._meta.model_name
|
|
||||||
perm_codename = (
|
|
||||||
f"change_{model_name}"
|
|
||||||
if operation == "set_permissions"
|
|
||||||
else f"delete_{model_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if apply_to_all:
|
objs = object_class.objects.select_related("owner").filter(pk__in=object_ids)
|
||||||
# Support all to avoid sending large lists of ids for bulk operations, with optional filters
|
|
||||||
filters = serializer.validated_data.get("filters") or {}
|
|
||||||
filterset_class = {
|
|
||||||
"tags": TagFilterSet,
|
|
||||||
"correspondents": CorrespondentFilterSet,
|
|
||||||
"document_types": DocumentTypeFilterSet,
|
|
||||||
"storage_paths": StoragePathFilterSet,
|
|
||||||
}[object_type]
|
|
||||||
user_permitted_objects = get_objects_for_user_owner_aware(
|
|
||||||
user,
|
|
||||||
perm_codename,
|
|
||||||
object_class,
|
|
||||||
)
|
|
||||||
objs = filterset_class(
|
|
||||||
data=filters,
|
|
||||||
queryset=user_permitted_objects,
|
|
||||||
).qs
|
|
||||||
if object_type == "tags":
|
|
||||||
editable_ids = set(user_permitted_objects.values_list("pk", flat=True))
|
|
||||||
all_ids = set(objs.values_list("pk", flat=True))
|
|
||||||
for tag in objs:
|
|
||||||
all_ids.update(
|
|
||||||
descendant.pk
|
|
||||||
for descendant in tag.get_descendants()
|
|
||||||
if descendant.pk in editable_ids
|
|
||||||
)
|
|
||||||
objs = object_class.objects.filter(pk__in=all_ids)
|
|
||||||
objs = objs.select_related("owner")
|
|
||||||
object_ids = list(objs.values_list("pk", flat=True))
|
|
||||||
else:
|
|
||||||
objs = object_class.objects.select_related("owner").filter(
|
|
||||||
pk__in=object_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user.is_superuser:
|
if not user.is_superuser:
|
||||||
perm = f"documents.{perm_codename}"
|
model_name = object_class._meta.model_name
|
||||||
|
perm = (
|
||||||
|
f"documents.change_{model_name}"
|
||||||
|
if operation == "set_permissions"
|
||||||
|
else f"documents.delete_{model_name}"
|
||||||
|
)
|
||||||
has_perms = user.has_perm(perm) and all(
|
has_perms = user.has_perm(perm) and all(
|
||||||
has_perms_owner_aware(user, perm_codename, obj) for obj in objs
|
(obj.owner == user or obj.owner is None) for obj in objs
|
||||||
)
|
)
|
||||||
|
|
||||||
if not has_perms:
|
if not has_perms:
|
||||||
|
|||||||
@@ -193,9 +193,11 @@ class ParserRegistry:
|
|||||||
that log output is predictable; scoring determines which parser wins
|
that log output is predictable; scoring determines which parser wins
|
||||||
at runtime regardless of registration order.
|
at runtime regardless of registration order.
|
||||||
"""
|
"""
|
||||||
|
from paperless.parsers.remote import RemoteDocumentParser
|
||||||
from paperless.parsers.text import TextDocumentParser
|
from paperless.parsers.text import TextDocumentParser
|
||||||
|
|
||||||
self.register_builtin(TextDocumentParser)
|
self.register_builtin(TextDocumentParser)
|
||||||
|
self.register_builtin(RemoteDocumentParser)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Discovery
|
# Discovery
|
||||||
|
|||||||
429
src/paperless/parsers/remote.py
Normal file
429
src/paperless/parsers/remote.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
"""
|
||||||
|
Built-in remote-OCR document parser.
|
||||||
|
|
||||||
|
Handles documents by sending them to a configured remote OCR engine
|
||||||
|
(currently Azure AI Vision / Document Intelligence) and retrieving both
|
||||||
|
the extracted text and a searchable PDF with an embedded text layer.
|
||||||
|
|
||||||
|
When no engine is configured, ``score()`` returns ``None`` so the parser
|
||||||
|
is effectively invisible to the registry — the tesseract parser handles
|
||||||
|
these MIME types instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from paperless.version import __full_version_str__
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import datetime
|
||||||
|
from types import TracebackType
|
||||||
|
|
||||||
|
from paperless.parsers import MetadataEntry
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.parsing.remote")
|
||||||
|
|
||||||
|
_SUPPORTED_MIME_TYPES: dict[str, str] = {
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/tiff": ".tiff",
|
||||||
|
"image/bmp": ".bmp",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEngineConfig:
|
||||||
|
"""Holds and validates the remote OCR engine configuration."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
engine: str | None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
endpoint: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.engine = engine
|
||||||
|
self.api_key = api_key
|
||||||
|
self.endpoint = endpoint
|
||||||
|
|
||||||
|
def engine_is_valid(self) -> bool:
|
||||||
|
"""Return True when the engine is known and fully configured."""
|
||||||
|
return (
|
||||||
|
self.engine in ("azureai",)
|
||||||
|
and self.api_key is not None
|
||||||
|
and not (self.engine == "azureai" and self.endpoint is None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteDocumentParser:
|
||||||
|
"""Parse documents via a remote OCR API (currently Azure AI Vision).
|
||||||
|
|
||||||
|
This parser sends documents to a remote engine that returns both
|
||||||
|
extracted text and a searchable PDF with an embedded text layer.
|
||||||
|
It does not depend on Tesseract or ocrmypdf.
|
||||||
|
|
||||||
|
Class attributes
|
||||||
|
----------------
|
||||||
|
name : str
|
||||||
|
Human-readable parser name.
|
||||||
|
version : str
|
||||||
|
Semantic version string, kept in sync with Paperless-ngx releases.
|
||||||
|
author : str
|
||||||
|
Maintainer name.
|
||||||
|
url : str
|
||||||
|
Issue tracker / source URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = "Paperless-ngx Remote OCR Parser"
|
||||||
|
version: str = __full_version_str__
|
||||||
|
author: str = "Paperless-ngx Contributors"
|
||||||
|
url: str = "https://github.com/paperless-ngx/paperless-ngx"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Class methods
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def supported_mime_types(cls) -> dict[str, str]:
|
||||||
|
"""Return the MIME types this parser can handle.
|
||||||
|
|
||||||
|
The full set is always returned regardless of whether a remote
|
||||||
|
engine is configured. The ``score()`` method handles the
|
||||||
|
"am I active?" logic by returning ``None`` when not configured.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict[str, str]
|
||||||
|
Mapping of MIME type to preferred file extension.
|
||||||
|
"""
|
||||||
|
return _SUPPORTED_MIME_TYPES
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def score(
|
||||||
|
cls,
|
||||||
|
mime_type: str,
|
||||||
|
filename: str,
|
||||||
|
path: Path | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
"""Return the priority score for handling this file, or None.
|
||||||
|
|
||||||
|
Returns ``None`` when no valid remote engine is configured,
|
||||||
|
making the parser invisible to the registry for this file.
|
||||||
|
When configured, returns 20 — higher than the Tesseract parser's
|
||||||
|
default of 10 — so the remote engine takes priority.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
mime_type:
|
||||||
|
Detected MIME type of the file.
|
||||||
|
filename:
|
||||||
|
Original filename including extension.
|
||||||
|
path:
|
||||||
|
Optional filesystem path. Not inspected by this parser.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int | None
|
||||||
|
20 when the remote engine is configured and the MIME type is
|
||||||
|
supported, otherwise None.
|
||||||
|
"""
|
||||||
|
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():
|
||||||
|
return None
|
||||||
|
if mime_type not in _SUPPORTED_MIME_TYPES:
|
||||||
|
return None
|
||||||
|
return 20
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Properties
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_produce_archive(self) -> bool:
|
||||||
|
"""Whether this parser can produce a searchable PDF archive copy.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
bool
|
||||||
|
Always True — the remote engine always returns a PDF with an
|
||||||
|
embedded text layer that serves as the archive copy.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_pdf_rendition(self) -> bool:
|
||||||
|
"""Whether the parser must produce a PDF for the frontend to display.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
bool
|
||||||
|
Always False — all supported originals are displayable by
|
||||||
|
the browser (PDF) or handled via the archive copy (images).
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def __init__(self, logging_group: object = None) -> None:
|
||||||
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._tempdir = Path(
|
||||||
|
tempfile.mkdtemp(prefix="paperless-", dir=settings.SCRATCH_DIR),
|
||||||
|
)
|
||||||
|
self._logging_group = logging_group
|
||||||
|
self._text: str | None = None
|
||||||
|
self._archive_path: Path | None = None
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_val: BaseException | None,
|
||||||
|
exc_tb: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
logger.debug("Cleaning up temporary directory %s", self._tempdir)
|
||||||
|
shutil.rmtree(self._tempdir, ignore_errors=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Core parsing interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse(
|
||||||
|
self,
|
||||||
|
document_path: Path,
|
||||||
|
mime_type: str,
|
||||||
|
*,
|
||||||
|
produce_archive: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Send the document to the remote engine and store results.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
document_path:
|
||||||
|
Absolute path to the document file to parse.
|
||||||
|
mime_type:
|
||||||
|
Detected MIME type of the document.
|
||||||
|
produce_archive:
|
||||||
|
Ignored — the remote engine always returns a searchable PDF,
|
||||||
|
which is stored as the archive copy regardless of this flag.
|
||||||
|
"""
|
||||||
|
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():
|
||||||
|
logger.warning(
|
||||||
|
"No valid remote parser engine is configured, content will be empty.",
|
||||||
|
)
|
||||||
|
self._text = ""
|
||||||
|
return
|
||||||
|
|
||||||
|
if config.engine == "azureai":
|
||||||
|
self._text = self._azure_ai_vision_parse(document_path, config)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Result accessors
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_text(self) -> str | None:
|
||||||
|
"""Return the plain-text content extracted during parse."""
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def get_date(self) -> datetime.datetime | None:
|
||||||
|
"""Return the document date detected during parse.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
datetime.datetime | None
|
||||||
|
Always None — the remote parser does not detect dates.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_archive_path(self) -> Path | None:
|
||||||
|
"""Return the path to the generated archive PDF, or None."""
|
||||||
|
return self._archive_path
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Thumbnail and metadata
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
|
||||||
|
"""Generate a thumbnail image for the document.
|
||||||
|
|
||||||
|
Uses the archive PDF produced by the remote engine when available,
|
||||||
|
otherwise falls back to the original document path (PDF inputs).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
document_path:
|
||||||
|
Absolute path to the source document.
|
||||||
|
mime_type:
|
||||||
|
Detected MIME type of the document.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Path
|
||||||
|
Path to the generated WebP thumbnail inside the temp directory.
|
||||||
|
"""
|
||||||
|
# make_thumbnail_from_pdf lives in documents.parsers for now;
|
||||||
|
# it will move to paperless.parsers.utils when the tesseract
|
||||||
|
# parser is migrated in a later phase.
|
||||||
|
from documents.parsers import make_thumbnail_from_pdf
|
||||||
|
|
||||||
|
return make_thumbnail_from_pdf(
|
||||||
|
self._archive_path or document_path,
|
||||||
|
self._tempdir,
|
||||||
|
self._logging_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_page_count(
|
||||||
|
self,
|
||||||
|
document_path: Path,
|
||||||
|
mime_type: str,
|
||||||
|
) -> int | None:
|
||||||
|
"""Return the number of pages in a PDF document.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
document_path:
|
||||||
|
Absolute path to the source document.
|
||||||
|
mime_type:
|
||||||
|
Detected MIME type of the document.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int | None
|
||||||
|
Page count for PDF inputs, or ``None`` for other MIME types.
|
||||||
|
"""
|
||||||
|
if mime_type != "application/pdf":
|
||||||
|
return None
|
||||||
|
|
||||||
|
from paperless.parsers.utils import get_page_count_for_pdf
|
||||||
|
|
||||||
|
return get_page_count_for_pdf(document_path, log=logger)
|
||||||
|
|
||||||
|
def extract_metadata(
|
||||||
|
self,
|
||||||
|
document_path: Path,
|
||||||
|
mime_type: str,
|
||||||
|
) -> list[MetadataEntry]:
|
||||||
|
"""Extract format-specific metadata from the document.
|
||||||
|
|
||||||
|
Delegates to the shared pikepdf-based extractor for PDF files.
|
||||||
|
Returns ``[]`` for all other MIME types.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
document_path:
|
||||||
|
Absolute path to the file to extract metadata from.
|
||||||
|
mime_type:
|
||||||
|
MIME type of the file. May be ``"application/pdf"`` when
|
||||||
|
called for the archive version of an image original.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[MetadataEntry]
|
||||||
|
Zero or more metadata entries.
|
||||||
|
"""
|
||||||
|
if mime_type != "application/pdf":
|
||||||
|
return []
|
||||||
|
|
||||||
|
from paperless.parsers.utils import extract_pdf_metadata
|
||||||
|
|
||||||
|
return extract_pdf_metadata(document_path, log=logger)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Private helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _azure_ai_vision_parse(
|
||||||
|
self,
|
||||||
|
file: Path,
|
||||||
|
config: RemoteEngineConfig,
|
||||||
|
) -> str | None:
|
||||||
|
"""Send ``file`` to Azure AI Document Intelligence and return text.
|
||||||
|
|
||||||
|
Downloads the searchable PDF output from Azure and stores it at
|
||||||
|
``self._archive_path``. Returns the extracted text content, or
|
||||||
|
``None`` on failure (the error is logged).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
file:
|
||||||
|
Absolute path to the document to analyse.
|
||||||
|
config:
|
||||||
|
Validated remote engine configuration.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str | None
|
||||||
|
Extracted text, or None if the Azure call failed.
|
||||||
|
"""
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
# Callers must have already validated config via engine_is_valid():
|
||||||
|
# engine_is_valid() asserts api_key is not None and (for azureai)
|
||||||
|
# endpoint is not None, so these casts are provably safe.
|
||||||
|
assert config.endpoint is not None
|
||||||
|
assert config.api_key is not None
|
||||||
|
|
||||||
|
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
||||||
|
from azure.ai.documentintelligence.models import AnalyzeDocumentRequest
|
||||||
|
from azure.ai.documentintelligence.models import AnalyzeOutputOption
|
||||||
|
from azure.ai.documentintelligence.models import DocumentContentFormat
|
||||||
|
from azure.core.credentials import AzureKeyCredential
|
||||||
|
|
||||||
|
client = DocumentIntelligenceClient(
|
||||||
|
endpoint=config.endpoint,
|
||||||
|
credential=AzureKeyCredential(config.api_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with file.open("rb") as f:
|
||||||
|
analyze_request = AnalyzeDocumentRequest(bytes_source=f.read())
|
||||||
|
poller = client.begin_analyze_document(
|
||||||
|
model_id="prebuilt-read",
|
||||||
|
body=analyze_request,
|
||||||
|
output_content_format=DocumentContentFormat.TEXT,
|
||||||
|
output=[AnalyzeOutputOption.PDF],
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
poller.wait()
|
||||||
|
result_id = poller.details["operation_id"]
|
||||||
|
result = poller.result()
|
||||||
|
|
||||||
|
self._archive_path = self._tempdir / "archive.pdf"
|
||||||
|
with self._archive_path.open("wb") as f:
|
||||||
|
for chunk in client.get_analyze_result_pdf(
|
||||||
|
model_id="prebuilt-read",
|
||||||
|
result_id=result_id,
|
||||||
|
):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
return result.content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Azure AI Vision parsing failed: %s", e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
return None
|
||||||
130
src/paperless/parsers/utils.py
Normal file
130
src/paperless/parsers/utils.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Shared utilities for Paperless-ngx document parsers.
|
||||||
|
|
||||||
|
Functions here are format-neutral helpers that multiple parsers need.
|
||||||
|
Keeping them here avoids parsers inheriting from each other just to
|
||||||
|
share implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from paperless.parsers import MetadataEntry
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.parsers.utils")
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_count_for_pdf(
|
||||||
|
document_path: Path,
|
||||||
|
log: logging.Logger | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
"""Return the number of pages in a PDF file using pikepdf.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
document_path:
|
||||||
|
Absolute path to the PDF file.
|
||||||
|
log:
|
||||||
|
Logger to use for warnings. Falls back to the module-level logger
|
||||||
|
when omitted.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int | None
|
||||||
|
Page count, or ``None`` if the file cannot be opened or is not a
|
||||||
|
valid PDF.
|
||||||
|
"""
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
|
_log = log or logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pikepdf.Pdf.open(document_path) as pdf:
|
||||||
|
return len(pdf.pages)
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning("Unable to determine PDF page count for %s: %s", document_path, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pdf_metadata(
|
||||||
|
document_path: Path,
|
||||||
|
log: logging.Logger | None = None,
|
||||||
|
) -> list[MetadataEntry]:
|
||||||
|
"""Extract XMP/PDF metadata from a PDF file using pikepdf.
|
||||||
|
|
||||||
|
Reads all XMP metadata entries from the document and returns them as a
|
||||||
|
list of ``MetadataEntry`` dicts. The method never raises — any failure
|
||||||
|
to open the file or read a specific key is logged and skipped.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
document_path:
|
||||||
|
Absolute path to the PDF file.
|
||||||
|
log:
|
||||||
|
Logger to use for warnings and debug messages. Falls back to the
|
||||||
|
module-level logger when omitted.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[MetadataEntry]
|
||||||
|
Zero or more metadata entries. Returns ``[]`` if the file cannot
|
||||||
|
be opened or contains no readable XMP metadata.
|
||||||
|
"""
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
|
from paperless.parsers import MetadataEntry
|
||||||
|
|
||||||
|
_log = log or logger
|
||||||
|
result: list[MetadataEntry] = []
|
||||||
|
namespace_pattern = re.compile(r"\{(.*)\}(.*)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf = pikepdf.open(document_path)
|
||||||
|
meta = pdf.open_metadata()
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning("Could not open PDF metadata for %s: %s", document_path, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
for key, value in meta.items():
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = " ".join(str(e) for e in value)
|
||||||
|
value = str(value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
m = namespace_pattern.match(key)
|
||||||
|
if m is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
namespace = m.group(1)
|
||||||
|
key_value = m.group(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
namespace.encode("utf-8")
|
||||||
|
key_value.encode("utf-8")
|
||||||
|
except UnicodeEncodeError as enc_err:
|
||||||
|
_log.debug("Skipping metadata key %s: %s", key, enc_err)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
MetadataEntry(
|
||||||
|
namespace=namespace,
|
||||||
|
prefix=meta.REVERSE_NS[namespace],
|
||||||
|
key=key_value,
|
||||||
|
value=value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(
|
||||||
|
"Error reading metadata key %s value %s: %s",
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -10,12 +10,15 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from paperless.parsers.remote import RemoteDocumentParser
|
||||||
from paperless.parsers.text import TextDocumentParser
|
from paperless.parsers.text import TextDocumentParser
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Text parser sample files
|
# Text parser sample files
|
||||||
@@ -74,3 +77,89 @@ def text_parser() -> Generator[TextDocumentParser, None, None]:
|
|||||||
"""
|
"""
|
||||||
with TextDocumentParser() as parser:
|
with TextDocumentParser() as parser:
|
||||||
yield parser
|
yield parser
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Remote parser sample files
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def remote_samples_dir(samples_dir: Path) -> Path:
|
||||||
|
"""Absolute path to the remote parser sample files directory.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Path
|
||||||
|
``<samples_dir>/remote/``
|
||||||
|
"""
|
||||||
|
return samples_dir / "remote"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def sample_pdf_file(remote_samples_dir: Path) -> Path:
|
||||||
|
"""Path to a simple digital PDF sample file.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Path
|
||||||
|
Absolute path to ``remote/simple-digital.pdf``.
|
||||||
|
"""
|
||||||
|
return remote_samples_dir / "simple-digital.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Remote parser instance
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def remote_parser() -> Generator[RemoteDocumentParser, None, None]:
|
||||||
|
"""Yield a RemoteDocumentParser and clean up its temporary directory afterwards.
|
||||||
|
|
||||||
|
Yields
|
||||||
|
------
|
||||||
|
RemoteDocumentParser
|
||||||
|
A ready-to-use parser instance.
|
||||||
|
"""
|
||||||
|
with RemoteDocumentParser() as parser:
|
||||||
|
yield parser
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Remote parser settings helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def azure_settings(settings: SettingsWrapper) -> SettingsWrapper:
|
||||||
|
"""Configure Django settings for a valid Azure AI OCR engine.
|
||||||
|
|
||||||
|
Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and
|
||||||
|
``REMOTE_OCR_ENDPOINT`` to test values. Settings are restored
|
||||||
|
automatically after the test by pytest-django.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
SettingsWrapper
|
||||||
|
The modified settings object (for chaining further overrides).
|
||||||
|
"""
|
||||||
|
settings.REMOTE_OCR_ENGINE = "azureai"
|
||||||
|
settings.REMOTE_OCR_API_KEY = "test-api-key"
|
||||||
|
settings.REMOTE_OCR_ENDPOINT = "https://test.cognitiveservices.azure.com"
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def no_engine_settings(settings: SettingsWrapper) -> SettingsWrapper:
|
||||||
|
"""Configure Django settings with no remote engine configured.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
SettingsWrapper
|
||||||
|
The modified settings object.
|
||||||
|
"""
|
||||||
|
settings.REMOTE_OCR_ENGINE = None
|
||||||
|
settings.REMOTE_OCR_API_KEY = None
|
||||||
|
settings.REMOTE_OCR_ENDPOINT = None
|
||||||
|
return settings
|
||||||
|
|||||||
490
src/paperless/tests/parsers/test_remote_parser.py
Normal file
490
src/paperless/tests/parsers/test_remote_parser.py
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
"""
|
||||||
|
Tests for paperless.parsers.remote.RemoteDocumentParser.
|
||||||
|
|
||||||
|
All tests use the context-manager protocol for parser lifecycle.
|
||||||
|
|
||||||
|
Fixture layout
|
||||||
|
--------------
|
||||||
|
make_azure_mock — factory (defined here; specific to this module)
|
||||||
|
azure_client — composes azure_settings + make_azure_mock + patch;
|
||||||
|
use when a test needs the client to succeed
|
||||||
|
failing_azure_client
|
||||||
|
— composes azure_settings + patch with RuntimeError;
|
||||||
|
use when a test needs the client to fail
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from paperless.parsers import ParserProtocol
|
||||||
|
from paperless.parsers.remote import RemoteDocumentParser
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-local fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_AZURE_CLIENT_TARGET = "azure.ai.documentintelligence.DocumentIntelligenceClient"
|
||||||
|
_DEFAULT_TEXT = "Extracted text."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def make_azure_mock() -> Callable[[str], Mock]:
|
||||||
|
"""Return a factory that builds a mock Azure DocumentIntelligenceClient.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
mock_client = make_azure_mock() # default extracted text
|
||||||
|
mock_client = make_azure_mock("My text.") # custom extracted text
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _factory(text: str = _DEFAULT_TEXT) -> Mock:
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_poller = Mock()
|
||||||
|
mock_poller.wait.return_value = None
|
||||||
|
mock_poller.details = {"operation_id": "fake-op-id"}
|
||||||
|
mock_poller.result.return_value.content = text
|
||||||
|
mock_client.begin_analyze_document.return_value = mock_poller
|
||||||
|
mock_client.get_analyze_result_pdf.return_value = [b"%PDF-1.4 FAKE"]
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def azure_client(
|
||||||
|
azure_settings: SettingsWrapper,
|
||||||
|
make_azure_mock: Callable[[str], Mock],
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> Mock:
|
||||||
|
"""Patch the Azure DI client with a succeeding mock and return the instance.
|
||||||
|
|
||||||
|
Implicitly applies ``azure_settings`` so tests using this fixture do not
|
||||||
|
also need ``@pytest.mark.usefixtures("azure_settings")``.
|
||||||
|
"""
|
||||||
|
mock_client = make_azure_mock()
|
||||||
|
mocker.patch(_AZURE_CLIENT_TARGET, return_value=mock_client)
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def failing_azure_client(
|
||||||
|
azure_settings: SettingsWrapper,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> Mock:
|
||||||
|
"""Patch the Azure DI client to raise RuntimeError on every call.
|
||||||
|
|
||||||
|
Implicitly applies ``azure_settings``. Returns the mock instance so
|
||||||
|
tests can assert on calls such as ``close()``.
|
||||||
|
"""
|
||||||
|
mock_client = Mock()
|
||||||
|
mock_client.begin_analyze_document.side_effect = RuntimeError("network failure")
|
||||||
|
mocker.patch(_AZURE_CLIENT_TARGET, return_value=mock_client)
|
||||||
|
return mock_client
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Protocol contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserProtocol:
|
||||||
|
"""Verify that RemoteDocumentParser satisfies the ParserProtocol contract."""
|
||||||
|
|
||||||
|
def test_isinstance_satisfies_protocol(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
) -> None:
|
||||||
|
assert isinstance(remote_parser, ParserProtocol)
|
||||||
|
|
||||||
|
def test_class_attributes_present(self) -> None:
|
||||||
|
assert isinstance(RemoteDocumentParser.name, str) and RemoteDocumentParser.name
|
||||||
|
assert (
|
||||||
|
isinstance(RemoteDocumentParser.version, str)
|
||||||
|
and RemoteDocumentParser.version
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
isinstance(RemoteDocumentParser.author, str) and RemoteDocumentParser.author
|
||||||
|
)
|
||||||
|
assert isinstance(RemoteDocumentParser.url, str) and RemoteDocumentParser.url
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# supported_mime_types
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserSupportedMimeTypes:
|
||||||
|
"""supported_mime_types() always returns the full set regardless of config."""
|
||||||
|
|
||||||
|
def test_returns_dict(self) -> None:
|
||||||
|
mime_types = RemoteDocumentParser.supported_mime_types()
|
||||||
|
assert isinstance(mime_types, dict)
|
||||||
|
|
||||||
|
def test_includes_all_expected_types(self) -> None:
|
||||||
|
mime_types = RemoteDocumentParser.supported_mime_types()
|
||||||
|
expected = {
|
||||||
|
"application/pdf",
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/tiff",
|
||||||
|
"image/bmp",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
}
|
||||||
|
assert expected == set(mime_types.keys())
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("no_engine_settings")
|
||||||
|
def test_returns_full_set_when_not_configured(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN: No remote engine is configured
|
||||||
|
WHEN: supported_mime_types() is called
|
||||||
|
THEN: The full MIME type dict is still returned (score() handles activation)
|
||||||
|
"""
|
||||||
|
mime_types = RemoteDocumentParser.supported_mime_types()
|
||||||
|
assert len(mime_types) == 7
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# score()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserScore:
|
||||||
|
"""score() encodes the activation logic: None when unconfigured, 20 when active."""
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("azure_settings")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mime_type",
|
||||||
|
[
|
||||||
|
pytest.param("application/pdf", id="pdf"),
|
||||||
|
pytest.param("image/png", id="png"),
|
||||||
|
pytest.param("image/jpeg", id="jpeg"),
|
||||||
|
pytest.param("image/tiff", id="tiff"),
|
||||||
|
pytest.param("image/bmp", id="bmp"),
|
||||||
|
pytest.param("image/gif", id="gif"),
|
||||||
|
pytest.param("image/webp", id="webp"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_score_returns_20_when_configured(self, mime_type: str) -> None:
|
||||||
|
result = RemoteDocumentParser.score(mime_type, "doc.pdf")
|
||||||
|
assert result == 20
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("no_engine_settings")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mime_type",
|
||||||
|
[
|
||||||
|
pytest.param("application/pdf", id="pdf"),
|
||||||
|
pytest.param("image/png", id="png"),
|
||||||
|
pytest.param("image/jpeg", id="jpeg"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_score_returns_none_when_no_engine(self, mime_type: str) -> None:
|
||||||
|
result = RemoteDocumentParser.score(mime_type, "doc.pdf")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_score_returns_none_when_api_key_missing(
|
||||||
|
self,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.REMOTE_OCR_ENGINE = "azureai"
|
||||||
|
settings.REMOTE_OCR_API_KEY = None
|
||||||
|
settings.REMOTE_OCR_ENDPOINT = "https://test.cognitiveservices.azure.com"
|
||||||
|
result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_score_returns_none_when_endpoint_missing(
|
||||||
|
self,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.REMOTE_OCR_ENGINE = "azureai"
|
||||||
|
settings.REMOTE_OCR_API_KEY = "key"
|
||||||
|
settings.REMOTE_OCR_ENDPOINT = None
|
||||||
|
result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("azure_settings")
|
||||||
|
def test_score_returns_none_for_unsupported_mime_type(self) -> None:
|
||||||
|
result = RemoteDocumentParser.score("text/plain", "doc.txt")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("azure_settings")
|
||||||
|
def test_score_higher_than_tesseract_default(self) -> None:
|
||||||
|
"""Remote parser (20) outranks the tesseract default (10) when configured."""
|
||||||
|
score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
||||||
|
assert score is not None and score > 10
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Properties
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserProperties:
|
||||||
|
def test_can_produce_archive_is_true(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
) -> None:
|
||||||
|
assert remote_parser.can_produce_archive is True
|
||||||
|
|
||||||
|
def test_requires_pdf_rendition_is_false(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
) -> None:
|
||||||
|
assert remote_parser.requires_pdf_rendition is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserLifecycle:
|
||||||
|
def test_context_manager_cleans_up_tempdir(self) -> None:
|
||||||
|
with RemoteDocumentParser() as parser:
|
||||||
|
tempdir = parser._tempdir
|
||||||
|
assert tempdir.exists()
|
||||||
|
assert not tempdir.exists()
|
||||||
|
|
||||||
|
def test_context_manager_cleans_up_after_exception(self) -> None:
|
||||||
|
tempdir: Path | None = None
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
with RemoteDocumentParser() as parser:
|
||||||
|
tempdir = parser._tempdir
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
assert tempdir is not None
|
||||||
|
assert not tempdir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse() — happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserParse:
|
||||||
|
def test_parse_returns_text_from_azure(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
azure_client: Mock,
|
||||||
|
) -> None:
|
||||||
|
remote_parser.parse(sample_pdf_file, "application/pdf")
|
||||||
|
|
||||||
|
assert remote_parser.get_text() == _DEFAULT_TEXT
|
||||||
|
|
||||||
|
def test_parse_sets_archive_path(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
azure_client: Mock,
|
||||||
|
) -> None:
|
||||||
|
remote_parser.parse(sample_pdf_file, "application/pdf")
|
||||||
|
|
||||||
|
archive = remote_parser.get_archive_path()
|
||||||
|
assert archive is not None
|
||||||
|
assert archive.exists()
|
||||||
|
assert archive.suffix == ".pdf"
|
||||||
|
|
||||||
|
def test_parse_closes_client_on_success(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
azure_client: Mock,
|
||||||
|
) -> None:
|
||||||
|
remote_parser.parse(sample_pdf_file, "application/pdf")
|
||||||
|
|
||||||
|
azure_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("no_engine_settings")
|
||||||
|
def test_parse_sets_empty_text_when_not_configured(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
) -> None:
|
||||||
|
remote_parser.parse(sample_pdf_file, "application/pdf")
|
||||||
|
|
||||||
|
assert remote_parser.get_text() == ""
|
||||||
|
assert remote_parser.get_archive_path() is None
|
||||||
|
|
||||||
|
def test_get_text_none_before_parse(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
) -> None:
|
||||||
|
assert remote_parser.get_text() is None
|
||||||
|
|
||||||
|
def test_get_date_always_none(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
azure_client: Mock,
|
||||||
|
) -> None:
|
||||||
|
remote_parser.parse(sample_pdf_file, "application/pdf")
|
||||||
|
|
||||||
|
assert remote_parser.get_date() is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse() — Azure failure path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserParseError:
|
||||||
|
def test_parse_returns_none_on_azure_error(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
failing_azure_client: Mock,
|
||||||
|
) -> None:
|
||||||
|
remote_parser.parse(sample_pdf_file, "application/pdf")
|
||||||
|
|
||||||
|
assert remote_parser.get_text() is None
|
||||||
|
|
||||||
|
def test_parse_closes_client_on_error(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
failing_azure_client: Mock,
|
||||||
|
) -> None:
|
||||||
|
remote_parser.parse(sample_pdf_file, "application/pdf")
|
||||||
|
|
||||||
|
failing_azure_client.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_parse_logs_error_on_azure_failure(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
failing_azure_client: Mock,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
mock_log = mocker.patch("paperless.parsers.remote.logger")
|
||||||
|
|
||||||
|
remote_parser.parse(sample_pdf_file, "application/pdf")
|
||||||
|
|
||||||
|
mock_log.error.assert_called_once()
|
||||||
|
assert "Azure AI Vision parsing failed" in mock_log.error.call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_page_count()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserPageCount:
|
||||||
|
def test_page_count_for_pdf(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
) -> None:
|
||||||
|
count = remote_parser.get_page_count(sample_pdf_file, "application/pdf")
|
||||||
|
assert isinstance(count, int)
|
||||||
|
assert count >= 1
|
||||||
|
|
||||||
|
def test_page_count_returns_none_for_image_mime(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
) -> None:
|
||||||
|
count = remote_parser.get_page_count(sample_pdf_file, "image/png")
|
||||||
|
assert count is None
|
||||||
|
|
||||||
|
def test_page_count_returns_none_for_invalid_pdf(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
bad_pdf = tmp_path / "bad.pdf"
|
||||||
|
bad_pdf.write_bytes(b"not a pdf at all")
|
||||||
|
count = remote_parser.get_page_count(bad_pdf, "application/pdf")
|
||||||
|
assert count is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# extract_metadata()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserMetadata:
|
||||||
|
def test_extract_metadata_non_pdf_returns_empty(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
) -> None:
|
||||||
|
result = remote_parser.extract_metadata(sample_pdf_file, "image/png")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_extract_metadata_pdf_returns_list(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
) -> None:
|
||||||
|
result = remote_parser.extract_metadata(sample_pdf_file, "application/pdf")
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
def test_extract_metadata_pdf_entries_have_required_keys(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
sample_pdf_file: Path,
|
||||||
|
) -> None:
|
||||||
|
result = remote_parser.extract_metadata(sample_pdf_file, "application/pdf")
|
||||||
|
for entry in result:
|
||||||
|
assert "namespace" in entry
|
||||||
|
assert "prefix" in entry
|
||||||
|
assert "key" in entry
|
||||||
|
assert "value" in entry
|
||||||
|
assert isinstance(entry["value"], str)
|
||||||
|
|
||||||
|
def test_extract_metadata_does_not_raise_on_invalid_pdf(
|
||||||
|
self,
|
||||||
|
remote_parser: RemoteDocumentParser,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
bad_pdf = tmp_path / "bad.pdf"
|
||||||
|
bad_pdf.write_bytes(b"not a pdf at all")
|
||||||
|
result = remote_parser.extract_metadata(bad_pdf, "application/pdf")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserRegistry:
|
||||||
|
def test_registered_in_defaults(self) -> None:
|
||||||
|
from paperless.parsers.registry import ParserRegistry
|
||||||
|
|
||||||
|
registry = ParserRegistry()
|
||||||
|
registry.register_defaults()
|
||||||
|
|
||||||
|
assert RemoteDocumentParser in registry._builtins
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("azure_settings")
|
||||||
|
def test_get_parser_returns_remote_when_configured(self) -> None:
|
||||||
|
from paperless.parsers.registry import get_parser_registry
|
||||||
|
|
||||||
|
registry = get_parser_registry()
|
||||||
|
parser_cls = registry.get_parser_for_file("application/pdf", "doc.pdf")
|
||||||
|
|
||||||
|
assert parser_cls is RemoteDocumentParser
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("no_engine_settings")
|
||||||
|
def test_get_parser_returns_none_for_pdf_when_not_configured(self) -> None:
|
||||||
|
"""With no tesseract parser registered yet, PDF has no handler if remote is off."""
|
||||||
|
from paperless.parsers.registry import ParserRegistry
|
||||||
|
|
||||||
|
registry = ParserRegistry()
|
||||||
|
registry.register_defaults()
|
||||||
|
parser_cls = registry.get_parser_for_file("application/pdf", "doc.pdf")
|
||||||
|
|
||||||
|
assert parser_cls is None
|
||||||
@@ -9,7 +9,6 @@ from allauth.mfa.recovery_codes.internal.flows import auto_generate_recovery_cod
|
|||||||
from allauth.mfa.totp.internal import auth as totp_auth
|
from allauth.mfa.totp.internal import auth as totp_auth
|
||||||
from allauth.socialaccount.adapter import get_adapter
|
from allauth.socialaccount.adapter import get_adapter
|
||||||
from allauth.socialaccount.models import SocialAccount
|
from allauth.socialaccount.models import SocialAccount
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
@@ -57,27 +56,17 @@ class StandardPagination(PageNumberPagination):
|
|||||||
page_size_query_param = "page_size"
|
page_size_query_param = "page_size"
|
||||||
max_page_size = 100000
|
max_page_size = 100000
|
||||||
|
|
||||||
def _get_api_version(self) -> int:
|
|
||||||
request = getattr(self, "request", None)
|
|
||||||
default_version = settings.REST_FRAMEWORK["DEFAULT_VERSION"]
|
|
||||||
return int(request.version if request else default_version)
|
|
||||||
|
|
||||||
def _should_include_all(self) -> bool:
|
|
||||||
# TODO: remove legacy `all` support when API v9 is dropped.
|
|
||||||
return self._get_api_version() < 10
|
|
||||||
|
|
||||||
def get_paginated_response(self, data):
|
def get_paginated_response(self, data):
|
||||||
response_data = [
|
|
||||||
("count", self.page.paginator.count),
|
|
||||||
("next", self.get_next_link()),
|
|
||||||
("previous", self.get_previous_link()),
|
|
||||||
]
|
|
||||||
if self._should_include_all():
|
|
||||||
response_data.append(("all", self.get_all_result_ids()))
|
|
||||||
response_data.append(("results", data))
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
OrderedDict(response_data),
|
OrderedDict(
|
||||||
|
[
|
||||||
|
("count", self.page.paginator.count),
|
||||||
|
("next", self.get_next_link()),
|
||||||
|
("previous", self.get_previous_link()),
|
||||||
|
("all", self.get_all_result_ids()),
|
||||||
|
("results", data),
|
||||||
|
],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_all_result_ids(self):
|
def get_all_result_ids(self):
|
||||||
@@ -98,14 +87,11 @@ class StandardPagination(PageNumberPagination):
|
|||||||
|
|
||||||
def get_paginated_response_schema(self, schema):
|
def get_paginated_response_schema(self, schema):
|
||||||
response_schema = super().get_paginated_response_schema(schema)
|
response_schema = super().get_paginated_response_schema(schema)
|
||||||
if self._should_include_all():
|
response_schema["properties"]["all"] = {
|
||||||
response_schema["properties"]["all"] = {
|
"type": "array",
|
||||||
"type": "array",
|
"example": "[1, 2, 3]",
|
||||||
"example": "[1, 2, 3]",
|
"items": {"type": "integer"},
|
||||||
"items": {"type": "integer"},
|
}
|
||||||
}
|
|
||||||
else:
|
|
||||||
response_schema["properties"].pop("all", None)
|
|
||||||
return response_schema
|
return response_schema
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from paperless_tesseract.parsers import RasterisedDocumentParser
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteEngineConfig:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
engine: str,
|
|
||||||
api_key: str | None = None,
|
|
||||||
endpoint: str | None = None,
|
|
||||||
):
|
|
||||||
self.engine = engine
|
|
||||||
self.api_key = api_key
|
|
||||||
self.endpoint = endpoint
|
|
||||||
|
|
||||||
def engine_is_valid(self):
|
|
||||||
valid = self.engine in ["azureai"] and self.api_key is not None
|
|
||||||
if self.engine == "azureai":
|
|
||||||
valid = valid and self.endpoint is not None
|
|
||||||
return valid
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteDocumentParser(RasterisedDocumentParser):
|
|
||||||
"""
|
|
||||||
This parser uses a remote OCR engine to parse documents. Currently, it supports Azure AI Vision
|
|
||||||
as this is the only service that provides a remote OCR API with text-embedded PDF output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logging_name = "paperless.parsing.remote"
|
|
||||||
|
|
||||||
def get_settings(self) -> RemoteEngineConfig:
|
|
||||||
"""
|
|
||||||
Returns the configuration for the remote OCR engine, loaded from Django settings.
|
|
||||||
"""
|
|
||||||
return RemoteEngineConfig(
|
|
||||||
engine=settings.REMOTE_OCR_ENGINE,
|
|
||||||
api_key=settings.REMOTE_OCR_API_KEY,
|
|
||||||
endpoint=settings.REMOTE_OCR_ENDPOINT,
|
|
||||||
)
|
|
||||||
|
|
||||||
def supported_mime_types(self):
|
|
||||||
if self.settings.engine_is_valid():
|
|
||||||
return {
|
|
||||||
"application/pdf": ".pdf",
|
|
||||||
"image/png": ".png",
|
|
||||||
"image/jpeg": ".jpg",
|
|
||||||
"image/tiff": ".tiff",
|
|
||||||
"image/bmp": ".bmp",
|
|
||||||
"image/gif": ".gif",
|
|
||||||
"image/webp": ".webp",
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def azure_ai_vision_parse(
|
|
||||||
self,
|
|
||||||
file: Path,
|
|
||||||
) -> str | None:
|
|
||||||
"""
|
|
||||||
Uses Azure AI Vision to parse the document and return the text content.
|
|
||||||
It requests a searchable PDF output with embedded text.
|
|
||||||
The PDF is saved to the archive_path attribute.
|
|
||||||
Returns the text content extracted from the document.
|
|
||||||
If the parsing fails, it returns None.
|
|
||||||
"""
|
|
||||||
from azure.ai.documentintelligence import DocumentIntelligenceClient
|
|
||||||
from azure.ai.documentintelligence.models import AnalyzeDocumentRequest
|
|
||||||
from azure.ai.documentintelligence.models import AnalyzeOutputOption
|
|
||||||
from azure.ai.documentintelligence.models import DocumentContentFormat
|
|
||||||
from azure.core.credentials import AzureKeyCredential
|
|
||||||
|
|
||||||
client = DocumentIntelligenceClient(
|
|
||||||
endpoint=self.settings.endpoint,
|
|
||||||
credential=AzureKeyCredential(self.settings.api_key),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with file.open("rb") as f:
|
|
||||||
analyze_request = AnalyzeDocumentRequest(bytes_source=f.read())
|
|
||||||
poller = client.begin_analyze_document(
|
|
||||||
model_id="prebuilt-read",
|
|
||||||
body=analyze_request,
|
|
||||||
output_content_format=DocumentContentFormat.TEXT,
|
|
||||||
output=[AnalyzeOutputOption.PDF], # request searchable PDF output
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
poller.wait()
|
|
||||||
result_id = poller.details["operation_id"]
|
|
||||||
result = poller.result()
|
|
||||||
|
|
||||||
# Download the PDF with embedded text
|
|
||||||
self.archive_path = self.tempdir / "archive.pdf"
|
|
||||||
with self.archive_path.open("wb") as f:
|
|
||||||
for chunk in client.get_analyze_result_pdf(
|
|
||||||
model_id="prebuilt-read",
|
|
||||||
result_id=result_id,
|
|
||||||
):
|
|
||||||
f.write(chunk)
|
|
||||||
return result.content
|
|
||||||
except Exception as e:
|
|
||||||
self.log.error(f"Azure AI Vision parsing failed: {e}")
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse(self, document_path: Path, mime_type, file_name=None):
|
|
||||||
if not self.settings.engine_is_valid():
|
|
||||||
self.log.warning(
|
|
||||||
"No valid remote parser engine is configured, content will be empty.",
|
|
||||||
)
|
|
||||||
self.text = ""
|
|
||||||
elif self.settings.engine == "azureai":
|
|
||||||
self.text = self.azure_ai_vision_parse(document_path)
|
|
||||||
@@ -1,16 +1,36 @@
|
|||||||
def get_parser(*args, **kwargs):
|
from __future__ import annotations
|
||||||
from paperless_remote.parsers import RemoteDocumentParser
|
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
from paperless.parsers.remote import RemoteDocumentParser
|
||||||
|
|
||||||
|
# The new RemoteDocumentParser does not accept the progress_callback
|
||||||
|
# kwarg injected by the old signal-based consumer. logging_group is
|
||||||
|
# forwarded as a positional arg.
|
||||||
|
# Phase 4 will replace this signal path with the new ParserRegistry.
|
||||||
|
kwargs.pop("progress_callback", None)
|
||||||
return RemoteDocumentParser(*args, **kwargs)
|
return RemoteDocumentParser(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_supported_mime_types():
|
def get_supported_mime_types() -> dict[str, str]:
|
||||||
from paperless_remote.parsers import RemoteDocumentParser
|
from django.conf import settings
|
||||||
|
|
||||||
return RemoteDocumentParser(None).supported_mime_types()
|
from paperless.parsers.remote import RemoteDocumentParser
|
||||||
|
from paperless.parsers.remote import RemoteEngineConfig
|
||||||
|
|
||||||
|
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():
|
||||||
|
return {}
|
||||||
|
return RemoteDocumentParser.supported_mime_types()
|
||||||
|
|
||||||
|
|
||||||
def remote_consumer_declaration(sender, **kwargs):
|
def remote_consumer_declaration(sender: Any, **kwargs: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"parser": get_parser,
|
"parser": get_parser,
|
||||||
"weight": 5,
|
"weight": 5,
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.test import override_settings
|
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
|
||||||
from paperless_remote.parsers import RemoteDocumentParser
|
|
||||||
from paperless_remote.signals import get_parser
|
|
||||||
|
|
||||||
|
|
||||||
class TestParser(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|
||||||
SAMPLE_FILES = Path(__file__).resolve().parent / "samples"
|
|
||||||
|
|
||||||
def assertContainsStrings(self, content: str, strings: list[str]) -> None:
|
|
||||||
# Asserts that all strings appear in content, in the given order.
|
|
||||||
indices = []
|
|
||||||
for s in strings:
|
|
||||||
if s in content:
|
|
||||||
indices.append(content.index(s))
|
|
||||||
else:
|
|
||||||
self.fail(f"'{s}' is not in '{content}'")
|
|
||||||
self.assertListEqual(indices, sorted(indices))
|
|
||||||
|
|
||||||
@mock.patch("paperless_tesseract.parsers.run_subprocess")
|
|
||||||
@mock.patch("azure.ai.documentintelligence.DocumentIntelligenceClient")
|
|
||||||
def test_get_text_with_azure(self, mock_client_cls, mock_subprocess) -> None:
|
|
||||||
# Arrange mock Azure client
|
|
||||||
mock_client = mock.Mock()
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
# Simulate poller result and its `.details`
|
|
||||||
mock_poller = mock.Mock()
|
|
||||||
mock_poller.wait.return_value = None
|
|
||||||
mock_poller.details = {"operation_id": "fake-op-id"}
|
|
||||||
mock_client.begin_analyze_document.return_value = mock_poller
|
|
||||||
mock_poller.result.return_value.content = "This is a test document."
|
|
||||||
|
|
||||||
# Return dummy PDF bytes
|
|
||||||
mock_client.get_analyze_result_pdf.return_value = [
|
|
||||||
b"%PDF-",
|
|
||||||
b"1.7 ",
|
|
||||||
b"FAKEPDF",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Simulate pdftotext by writing dummy text to sidecar file
|
|
||||||
def fake_run(cmd, *args, **kwargs) -> None:
|
|
||||||
with Path(cmd[-1]).open("w", encoding="utf-8") as f:
|
|
||||||
f.write("This is a test document.")
|
|
||||||
|
|
||||||
mock_subprocess.side_effect = fake_run
|
|
||||||
|
|
||||||
with override_settings(
|
|
||||||
REMOTE_OCR_ENGINE="azureai",
|
|
||||||
REMOTE_OCR_API_KEY="somekey",
|
|
||||||
REMOTE_OCR_ENDPOINT="https://endpoint.cognitiveservices.azure.com",
|
|
||||||
):
|
|
||||||
parser = get_parser(uuid.uuid4())
|
|
||||||
parser.parse(
|
|
||||||
self.SAMPLE_FILES / "simple-digital.pdf",
|
|
||||||
"application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertContainsStrings(
|
|
||||||
parser.text.strip(),
|
|
||||||
["This is a test document."],
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("azure.ai.documentintelligence.DocumentIntelligenceClient")
|
|
||||||
def test_get_text_with_azure_error_logged_and_returns_none(
|
|
||||||
self,
|
|
||||||
mock_client_cls,
|
|
||||||
) -> None:
|
|
||||||
mock_client = mock.Mock()
|
|
||||||
mock_client.begin_analyze_document.side_effect = RuntimeError("fail")
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
with override_settings(
|
|
||||||
REMOTE_OCR_ENGINE="azureai",
|
|
||||||
REMOTE_OCR_API_KEY="somekey",
|
|
||||||
REMOTE_OCR_ENDPOINT="https://endpoint.cognitiveservices.azure.com",
|
|
||||||
):
|
|
||||||
parser = get_parser(uuid.uuid4())
|
|
||||||
with mock.patch.object(parser.log, "error") as mock_log_error:
|
|
||||||
parser.parse(
|
|
||||||
self.SAMPLE_FILES / "simple-digital.pdf",
|
|
||||||
"application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIsNone(parser.text)
|
|
||||||
mock_client.begin_analyze_document.assert_called_once()
|
|
||||||
mock_client.close.assert_called_once()
|
|
||||||
mock_log_error.assert_called_once()
|
|
||||||
self.assertIn(
|
|
||||||
"Azure AI Vision parsing failed",
|
|
||||||
mock_log_error.call_args[0][0],
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
REMOTE_OCR_ENGINE="azureai",
|
|
||||||
REMOTE_OCR_API_KEY="key",
|
|
||||||
REMOTE_OCR_ENDPOINT="https://endpoint.cognitiveservices.azure.com",
|
|
||||||
)
|
|
||||||
def test_supported_mime_types_valid_config(self) -> None:
|
|
||||||
parser = RemoteDocumentParser(uuid.uuid4())
|
|
||||||
expected_types = {
|
|
||||||
"application/pdf": ".pdf",
|
|
||||||
"image/png": ".png",
|
|
||||||
"image/jpeg": ".jpg",
|
|
||||||
"image/tiff": ".tiff",
|
|
||||||
"image/bmp": ".bmp",
|
|
||||||
"image/gif": ".gif",
|
|
||||||
"image/webp": ".webp",
|
|
||||||
}
|
|
||||||
self.assertEqual(parser.supported_mime_types(), expected_types)
|
|
||||||
|
|
||||||
def test_supported_mime_types_invalid_config(self) -> None:
|
|
||||||
parser = get_parser(uuid.uuid4())
|
|
||||||
self.assertEqual(parser.supported_mime_types(), {})
|
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
REMOTE_OCR_ENGINE=None,
|
|
||||||
REMOTE_OCR_API_KEY=None,
|
|
||||||
REMOTE_OCR_ENDPOINT=None,
|
|
||||||
)
|
|
||||||
def test_parse_with_invalid_config(self) -> None:
|
|
||||||
parser = get_parser(uuid.uuid4())
|
|
||||||
parser.parse(self.SAMPLE_FILES / "simple-digital.pdf", "application/pdf")
|
|
||||||
self.assertEqual(parser.text, "")
|
|
||||||
@@ -221,7 +221,7 @@ class RasterisedDocumentParser(DocumentParser):
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert isinstance(self.settings, OcrConfig)
|
assert isinstance(self.settings, OcrConfig)
|
||||||
ocrmypdf_args = {
|
ocrmypdf_args = {
|
||||||
"input_file": input_file,
|
"input_file_or_options": input_file,
|
||||||
"output_file": output_file,
|
"output_file": output_file,
|
||||||
# need to use threads, since this will be run in daemonized
|
# need to use threads, since this will be run in daemonized
|
||||||
# processes via the task library.
|
# processes via the task library.
|
||||||
@@ -285,7 +285,7 @@ class RasterisedDocumentParser(DocumentParser):
|
|||||||
"for compatibility with img2pdf",
|
"for compatibility with img2pdf",
|
||||||
)
|
)
|
||||||
# Replace the input file with the non-alpha
|
# Replace the input file with the non-alpha
|
||||||
ocrmypdf_args["input_file"] = self.remove_alpha(input_file)
|
ocrmypdf_args["input_file_or_options"] = self.remove_alpha(input_file)
|
||||||
|
|
||||||
if dpi:
|
if dpi:
|
||||||
self.log.debug(f"Detected DPI for image {input_file}: {dpi}")
|
self.log.debug(f"Detected DPI for image {input_file}: {dpi}")
|
||||||
|
|||||||
@@ -778,7 +778,7 @@ class TestParser(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
safe_fallback=False,
|
safe_fallback=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(params["input_file"], "input.pdf")
|
self.assertEqual(params["input_file_or_options"], "input.pdf")
|
||||||
self.assertEqual(params["output_file"], "output.pdf")
|
self.assertEqual(params["output_file"], "output.pdf")
|
||||||
self.assertEqual(params["sidecar"], "sidecar.txt")
|
self.assertEqual(params["sidecar"], "sidecar.txt")
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
def get_parser(*args, **kwargs):
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser(*args: Any, **kwargs: Any) -> Any:
|
||||||
from paperless.parsers.text import TextDocumentParser
|
from paperless.parsers.text import TextDocumentParser
|
||||||
|
|
||||||
# The new TextDocumentParser does not accept the legacy logging_group /
|
# The new TextDocumentParser does not accept the progress_callback
|
||||||
# progress_callback kwargs injected by the old signal-based consumer.
|
# kwarg injected by the old signal-based consumer. logging_group is
|
||||||
# These are dropped here; Phase 4 will replace this signal path with the
|
# forwarded as a positional arg.
|
||||||
# new ParserRegistry so the shim can be removed at that point.
|
# Phase 4 will replace this signal path with the new ParserRegistry.
|
||||||
kwargs.pop("logging_group", None)
|
|
||||||
kwargs.pop("progress_callback", None)
|
kwargs.pop("progress_callback", None)
|
||||||
return TextDocumentParser()
|
return TextDocumentParser(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def text_consumer_declaration(sender, **kwargs):
|
def text_consumer_declaration(sender: Any, **kwargs: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"parser": get_parser,
|
"parser": get_parser,
|
||||||
"weight": 10,
|
"weight": 10,
|
||||||
|
|||||||
115
uv.lock
generated
115
uv.lock
generated
@@ -831,6 +831,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defusedxml"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deprecated"
|
name = "deprecated"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -1283,6 +1292,59 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fonttools"
|
||||||
|
version = "4.62.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/96/686339e0fda8142b7ebed39af53f4a5694602a729662f42a6209e3be91d0/fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098", size = 3579521, upload-time = "2026-03-09T16:50:06.217Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/33/63d79ca41020dd460b51f1e0f58ad1ff0a36b7bcbdf8f3971d52836581e9/fonttools-4.62.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:196cafef9aeec5258425bd31a4e9a414b2ee0d1557bca184d7923d3d3bcd90f9", size = 2870816, upload-time = "2026-03-09T16:48:32.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/7a/9aeec114bc9fc00d757a41f092f7107863d372e684a5b5724c043654477c/fonttools-4.62.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:153afc3012ff8761b1733e8fbe5d98623409774c44ffd88fbcb780e240c11d13", size = 2416127, upload-time = "2026-03-09T16:48:34.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/71/12cfd8ae0478b7158ffa8850786781f67e73c00fd897ef9d053415c5f88b/fonttools-4.62.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13b663fb197334de84db790353d59da2a7288fd14e9be329f5debc63ec0500a5", size = 5100678, upload-time = "2026-03-09T16:48:36.454Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/d7/8e4845993ee233c2023d11babe9b3dae7d30333da1d792eeccebcb77baab/fonttools-4.62.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:591220d5333264b1df0d3285adbdfe2af4f6a45bbf9ca2b485f97c9f577c49ff", size = 5070859, upload-time = "2026-03-09T16:48:38.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/a0/287ae04cd883a52e7bb1d92dfc4997dcffb54173761c751106845fa9e316/fonttools-4.62.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:579f35c121528a50c96bf6fcb6a393e81e7f896d4326bf40e379f1c971603db9", size = 5076689, upload-time = "2026-03-09T16:48:41.886Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/4e/a2377ad26c36fcd3e671a1c316ea5ed83107de1588e2d897a98349363bc7/fonttools-4.62.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:44956b003151d5a289eba6c71fe590d63509267c37e26de1766ba15d9c589582", size = 5202053, upload-time = "2026-03-09T16:48:43.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/9d/7ad1ffc080619f67d0b1e0fa6a0578f0be077404f13fd8e448d1616a94a3/fonttools-4.62.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22bde4dc12a9e09b5ced77f3b5053d96cf10c4976c6ac0dee293418ef289d221", size = 2870004, upload-time = "2026-03-09T16:48:50.837Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/8b/ba59069a490f61b737e064c3129453dbd28ee38e81d56af0d04d7e6b4de4/fonttools-4.62.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7199c73b326bad892f1cb53ffdd002128bfd58a89b8f662204fbf1daf8d62e85", size = 2414662, upload-time = "2026-03-09T16:48:53.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/8c/c52a4310de58deeac7e9ea800892aec09b00bb3eb0c53265b31ec02be115/fonttools-4.62.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d732938633681d6e2324e601b79e93f7f72395ec8681f9cdae5a8c08bc167e72", size = 5032975, upload-time = "2026-03-09T16:48:55.718Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a1/d16318232964d786907b9b3613b8409f74cf0be2da400854509d3a864e43/fonttools-4.62.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a804c16d76038cc4e3826e07678efb0a02dc4f15396ea8e07088adbfb2578e", size = 4988544, upload-time = "2026-03-09T16:48:57.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/8d/7e745ca3e65852adc5e52a83dc213fe1b07d61cb5b394970fcd4b1199d1e/fonttools-4.62.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:090e74ac86e68c20150e665ef8e7e0c20cb9f8b395302c9419fa2e4d332c3b51", size = 4971296, upload-time = "2026-03-09T16:48:59.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/d4/b717a4874175146029ca1517e85474b1af80c9d9a306fc3161e71485eea5/fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5", size = 5122503, upload-time = "2026-03-09T16:49:02.464Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/c7/985c1670aa6d82ef270f04cde11394c168f2002700353bd2bde405e59b8f/fonttools-4.62.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:274c8b8a87e439faf565d3bcd3f9f9e31bca7740755776a4a90a4bfeaa722efa", size = 2864929, upload-time = "2026-03-09T16:49:09.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/dc/c409c8ceec0d3119e9ab0b7b1a2e3c76d1f4d66e4a9db5c59e6b7652e7df/fonttools-4.62.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93e27131a5a0ae82aaadcffe309b1bae195f6711689722af026862bede05c07c", size = 2412586, upload-time = "2026-03-09T16:49:11.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/ac/8e300dbf7b4d135287c261ffd92ede02d9f48f0d2db14665fbc8b059588a/fonttools-4.62.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c6524c5b93bad9c2939d88e619fedc62e913c19e673f25d5ab74e7a5d074e5", size = 5013708, upload-time = "2026-03-09T16:49:14.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/bc/60d93477b653eeb1ddf5f9ec34be689b79234d82dbdded269ac0252715b8/fonttools-4.62.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:106aec9226f9498fc5345125ff7200842c01eda273ae038f5049b0916907acee", size = 4964355, upload-time = "2026-03-09T16:49:16.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/eb/6dc62bcc3c3598c28a3ecb77e69018869c3e109bd83031d4973c059d318b/fonttools-4.62.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d86b96c79013320f13bc1b15f94789edb376c0a2d22fb6088f33637e8dfcbc", size = 4953472, upload-time = "2026-03-09T16:49:18.494Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/b3/3af7592d9b254b7b7fec018135f8776bfa0d1ad335476c2791b1334dc5e4/fonttools-4.62.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f16c07e5250d5d71d0f990a59460bc5620c3cc456121f2cfb5b60475699905f", size = 5094701, upload-time = "2026-03-09T16:49:21.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/64/61f69298aa6e7c363dcf00dd6371a654676900abe27d1effd1a74b43e5d0/fonttools-4.62.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4fa5a9c716e2f75ef34b5a5c2ca0ee4848d795daa7e6792bf30fd4abf8993449", size = 2864222, upload-time = "2026-03-09T16:49:28.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/57/6b08756fe4455336b1fe160ab3c11fccc90768ccb6ee03fb0b45851aace4/fonttools-4.62.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:625f5cbeb0b8f4e42343eaeb4bc2786718ddd84760a2f5e55fdd3db049047c00", size = 2410674, upload-time = "2026-03-09T16:49:30.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/86/db65b63bb1b824b63e602e9be21b18741ddc99bcf5a7850f9181159ae107/fonttools-4.62.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6247e58b96b982709cd569a91a2ba935d406dccf17b6aa615afaed37ac3856aa", size = 4999387, upload-time = "2026-03-09T16:49:32.593Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/c8/c6669e42d2f4efd60d38a3252cebbb28851f968890efb2b9b15f9d1092b0/fonttools-4.62.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:840632ea9c1eab7b7f01c369e408c0721c287dfd7500ab937398430689852fd1", size = 4912506, upload-time = "2026-03-09T16:49:34.927Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/49/0ae552aa098edd0ec548413fbf818f52ceb70535016215094a5ce9bf8f70/fonttools-4.62.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:28a9ea2a7467a816d1bec22658b0cce4443ac60abac3e293bdee78beb74588f3", size = 4951202, upload-time = "2026-03-09T16:49:37.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/65/ae38fc8a4cea6f162d74cf11f58e9aeef1baa7d0e3d1376dabd336c129e5/fonttools-4.62.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ae611294f768d413949fd12693a8cba0e6332fbc1e07aba60121be35eac68d0", size = 5060758, upload-time = "2026-03-09T16:49:39.464Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/65/f47f9b3db1ec156a1f222f1089ba076b2cc9ee1d024a8b0a60c54258517e/fonttools-4.62.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0361a7d41d86937f1f752717c19f719d0fde064d3011038f9f19bdf5fc2f5c95", size = 2947079, upload-time = "2026-03-09T16:49:46.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/73/bc62e5058a0c22cf02b1e0169ef0c3ca6c3247216d719f95bead3c05a991/fonttools-4.62.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4108c12773b3c97aa592311557c405d5b4fc03db2b969ed928fcf68e7b3c887", size = 2448802, upload-time = "2026-03-09T16:49:48.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/df/bfaa0e845884935355670e6e68f137185ab87295f8bc838db575e4a66064/fonttools-4.62.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b448075f32708e8fb377fe7687f769a5f51a027172c591ba9a58693631b077a8", size = 5137378, upload-time = "2026-03-09T16:49:50.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/32/04f616979a18b48b52e634988b93d847b6346260faf85ecccaf7e2e9057f/fonttools-4.62.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5f1fa8cc9f1a56a3e33ee6b954d6d9235e6b9d11eb7a6c9dfe2c2f829dc24db", size = 4920714, upload-time = "2026-03-09T16:49:53.172Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/2e/274e16689c1dfee5c68302cd7c444213cfddd23cf4620374419625037ec6/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f8c8ea812f82db1e884b9cdb663080453e28f0f9a1f5027a5adb59c4cc8d38d1", size = 5016012, upload-time = "2026-03-09T16:49:55.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/0c/b08117270626e7117ac2f89d732fdd4386ec37d2ab3a944462d29e6f89a1/fonttools-4.62.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:03c6068adfdc67c565d217e92386b1cdd951abd4240d65180cec62fa74ba31b2", size = 5042766, upload-time = "2026-03-09T16:49:57.726Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/57/c2487c281dde03abb2dec244fd67059b8d118bd30a653cbf69e94084cb23/fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3", size = 1152427, upload-time = "2026-03-09T16:50:04.074Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fpdf2"
|
||||||
|
version = "2.8.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "defusedxml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
{ name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/27/f2/72feae0b2827ed38013e4307b14f95bf0b3d124adfef4d38a7d57533f7be/fpdf2-2.8.7.tar.gz", hash = "sha256:7060ccee5a9c7ab0a271fb765a36a23639f83ef8996c34e3d46af0a17ede57f9", size = 362351, upload-time = "2026-02-28T05:39:16.456Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/0a/cf50ecffa1e3747ed9380a3adfc829259f1f86b3fdbd9e505af789003141/fpdf2-2.8.7-py3-none-any.whl", hash = "sha256:d391fc508a3ce02fc43a577c830cda4fe6f37646f2d143d489839940932fbc19", size = 327056, upload-time = "2026-02-28T05:39:14.619Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
@@ -2722,10 +2784,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ocrmypdf"
|
name = "ocrmypdf"
|
||||||
version = "16.13.0"
|
version = "17.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "deprecation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "deprecation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
{ name = "fpdf2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "img2pdf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "img2pdf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "pdfminer-six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "pdfminer-six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
@@ -2733,11 +2796,14 @@ dependencies = [
|
|||||||
{ name = "pikepdf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "pikepdf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
{ name = "pypdfium2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
{ name = "uharfbuzz", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/52/be1aaece0703a736757d8957c0d4f19c37561054169b501eb0e7132f15e5/ocrmypdf-16.13.0.tar.gz", hash = "sha256:29d37e915234ce717374863a9cc5dd32d29e063dfe60c51380dda71254c88248", size = 7042247, upload-time = "2025-12-24T07:58:35.86Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/fe/60bdc79529be1ad8b151d426ed2020d5ac90328c54e9ba92bd808e1535c1/ocrmypdf-17.3.0.tar.gz", hash = "sha256:4022f13aad3f405e330056a07aa8bd63714b48b414693831b56e2cf2c325f52d", size = 7378015, upload-time = "2026-02-21T09:30:07.207Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/b1/e2e7ad98de0d3ee05b44dbc3f78ccb158a620f3add82d00c85490120e7f2/ocrmypdf-16.13.0-py3-none-any.whl", hash = "sha256:fad8a6f7cc52cdc6225095c401a1766c778c47efe9f1e854ae4dc64a550a3d37", size = 165377, upload-time = "2025-12-24T07:58:33.925Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/b1/b7ae057a1bcb1495067ee3c4d48c1ce5fc66addd9492307c5a0ff799a7f2/ocrmypdf-17.3.0-py3-none-any.whl", hash = "sha256:c8882e7864954d3db6bcee49cc9f261b65bff66b7e5925eb68a1c281f41cad23", size = 488130, upload-time = "2026-02-21T09:30:05.236Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2978,7 +3044,7 @@ requires-dist = [
|
|||||||
{ name = "llama-index-vector-stores-faiss", specifier = ">=0.5.2" },
|
{ name = "llama-index-vector-stores-faiss", specifier = ">=0.5.2" },
|
||||||
{ name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" },
|
{ name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" },
|
||||||
{ name = "nltk", specifier = "~=3.9.1" },
|
{ name = "nltk", specifier = "~=3.9.1" },
|
||||||
{ name = "ocrmypdf", specifier = "~=16.13.0" },
|
{ name = "ocrmypdf", specifier = "~=17.3.0" },
|
||||||
{ name = "openai", specifier = ">=1.76" },
|
{ name = "openai", specifier = ">=1.76" },
|
||||||
{ name = "pathvalidate", specifier = "~=3.3.1" },
|
{ name = "pathvalidate", specifier = "~=3.3.1" },
|
||||||
{ name = "pdf2image", specifier = "~=1.17.0" },
|
{ name = "pdf2image", specifier = "~=1.17.0" },
|
||||||
@@ -3655,6 +3721,30 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pypdfium2"
|
||||||
|
version = "5.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/01/be763b9081c7eb823196e7d13d9c145bf75ac43f3c1466de81c21c24b381/pypdfium2-5.6.0.tar.gz", hash = "sha256:bcb9368acfe3547054698abbdae68ba0cbd2d3bda8e8ee437e061deef061976d", size = 270714, upload-time = "2026-03-08T01:05:06.5Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/f6/9f9e190fe0e5a6b86b82f83bd8b5d3490348766062381140ca5cad8e00b1/pypdfium2-5.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e468c38997573f0e86f03273c2c1fbdea999de52ba43fee96acaa2f6b2ad35f7", size = 3412541, upload-time = "2026-03-08T01:04:25.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/8d/e57492cb2228ba56ed57de1ff044c8ac114b46905f8b1445c33299ba0488/pypdfium2-5.6.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ad3abddc5805424f962e383253ccad6a0d1d2ebd86afa9a9e1b9ca659773cd0d", size = 3592320, upload-time = "2026-03-08T01:04:27.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/8a/8ab82e33e9c551494cbe1526ea250ca8cc4e9e98d6a4fc6b6f8d959aa1d1/pypdfium2-5.6.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6b5eb9eae5c45076395454522ca26add72ba8bd1fe473e1e4721aa58521470c", size = 3596450, upload-time = "2026-03-08T01:04:29.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/b5/602a792282312ccb158cc63849528079d94b0a11efdc61f2a359edfb41e9/pypdfium2-5.6.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:258624da8ef45cdc426e11b33e9d83f9fb723c1c201c6e0f4ab5a85966c6b876", size = 3325442, upload-time = "2026-03-08T01:04:30.886Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/1f/9e48ec05ed8d19d736c2d1f23c1bd0f20673f02ef846a2576c69e237f15d/pypdfium2-5.6.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9367451c8a00931d6612db0822525a18c06f649d562cd323a719e46ac19c9bb", size = 3727434, upload-time = "2026-03-08T01:04:33.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/90/0efd020928b4edbd65f4f3c2af0c84e20b43a3ada8fa6d04f999a97afe7a/pypdfium2-5.6.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a757869f891eac1cc1372e38a4aa01adac8abc8fe2a8a4e2ebf50595e3bf5937", size = 4139029, upload-time = "2026-03-08T01:04:36.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/49/a640b288a48dab1752281dd9b72c0679fccea107874e80a65a606b00efa9/pypdfium2-5.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:515be355222cc57ae9e62cd5c7c350b8e0c863efc539f80c7d75e2811ba45cb6", size = 3646387, upload-time = "2026-03-08T01:04:38.151Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/3b/a344c19c01021eeb5d830c102e4fc9b1602f19c04aa7d11abbe2d188fd8e/pypdfium2-5.6.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c4753c7caf7d004211d7f57a21f10d127f5e0e5510a14d24bc073e7220a3ea", size = 3097212, upload-time = "2026-03-08T01:04:40.776Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/96/e48e13789ace22aeb9b7510904a1b1493ec588196e11bbacc122da330b3d/pypdfium2-5.6.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49729090281fdd85775fb8912c10bd19e99178efaa98f145ab06e7ce68554d2", size = 2965026, upload-time = "2026-03-08T01:04:42.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/06/3100e44d4935f73af8f5d633d3bd40f0d36d606027085a0ef1f0566a6320/pypdfium2-5.6.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a4a1749a8d4afd62924a8d95cfa4f2e26fc32957ce34ac3b674be6f127ed252e", size = 4131431, upload-time = "2026-03-08T01:04:44.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/ef/d8df63569ce9a66c8496057782eb8af78e0d28667922d62ec958434e3d4b/pypdfium2-5.6.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:36469ebd0fdffb7130ce45ed9c44f8232d91571c89eb851bd1633c64b6f6114f", size = 3747469, upload-time = "2026-03-08T01:04:46.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/47/fd2c6a67a49fade1acd719fbd11f7c375e7219912923ef2de0ea0ac1544e/pypdfium2-5.6.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9da900df09be3cf546b637a127a7b6428fb22d705951d731269e25fd3adef457", size = 4337578, upload-time = "2026-03-08T01:04:49.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/f5/836c83e54b01e09478c4d6bf4912651d6053c932250fcee953f5c72d8e4a/pypdfium2-5.6.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:45fccd5622233c5ec91a885770ae7dd4004d4320ac05a4ad8fa03a66dea40244", size = 4376104, upload-time = "2026-03-08T01:04:51.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/7f/b940b6a1664daf8f9bad87c6c99b84effa3611615b8708d10392dc33036c/pypdfium2-5.6.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:282dc030e767cd61bd0299f9d581052b91188e2b87561489057a8e7963e7e0cb", size = 3929824, upload-time = "2026-03-08T01:04:53.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/79/00267d92a6a58c229e364d474f5698efe446e0c7f4f152f58d0138715e99/pypdfium2-5.6.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a1c1dfe950382c76a7bba1ba160ec5e40df8dd26b04a1124ae268fda55bc4cbe", size = 4270201, upload-time = "2026-03-08T01:04:55.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/ab/b127f38aba41746bdf9ace15ba08411d7ef6ecba1326d529ba414eb1ed50/pypdfium2-5.6.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:43b0341ca6feb6c92e4b7a9eb4813e5466f5f5e8b6baeb14df0a94d5f312c00b", size = 4180793, upload-time = "2026-03-08T01:04:57.961Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyrefly"
|
name = "pyrefly"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
@@ -5143,6 +5233,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/512aeb40fd819f4660d00f96f5c7371ee36fc8c6b605128c5ee59e0b28c6/u_msgpack_python-2.8.0-py2.py3-none-any.whl", hash = "sha256:1d853d33e78b72c4228a2025b4db28cda81214076e5b0422ed0ae1b1b2bb586a", size = 10590, upload-time = "2023-05-18T09:28:10.323Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/512aeb40fd819f4660d00f96f5c7371ee36fc8c6b605128c5ee59e0b28c6/u_msgpack_python-2.8.0-py2.py3-none-any.whl", hash = "sha256:1d853d33e78b72c4228a2025b4db28cda81214076e5b0422ed0ae1b1b2bb586a", size = 10590, upload-time = "2023-05-18T09:28:10.323Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uharfbuzz"
|
||||||
|
version = "0.53.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/8d/7c82298bfa5c96f018541661bc2ccdf90dfe397bb2724db46725bf495466/uharfbuzz-0.53.3.tar.gz", hash = "sha256:9a87175c14d1361322ce2a3504e63c6b66062934a5edf47266aed5b33416806c", size = 1714488, upload-time = "2026-01-24T13:10:43.693Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/88/5df9337adb60d7b1ad150b162bbc5c56d783d15546714085d92b9531f8f3/uharfbuzz-0.53.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d977e41a501d9e8af3f2c329d75031037ee79634bc29ca3872e9115c44e67d25", size = 2722639, upload-time = "2026-01-24T13:10:22.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/c4/8b4b050e77d6cb9a84af509e5796734f0e687bd02ad11757a581bd6f197d/uharfbuzz-0.53.3-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21d512c94aa992691aaf5b433deaca7e51f4ea54c68b99f535974073364f806f", size = 1647506, upload-time = "2026-01-24T13:10:24.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/ff/8e7cf78d525604f3e0a43b9468263fcf2acb5d208a3979c3bfa8dc61112d/uharfbuzz-0.53.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dca9a2e071c0c59ba8f382356f31a2518ac3dc7cc77e4f3519defc454c5b9a97", size = 1706448, upload-time = "2026-01-24T13:10:25.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/a0/739471cdd52723ecc9fc80f36fb92c706a87265dc258521c1b14d99414f7/uharfbuzz-0.53.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1191a74ddcf18ec721161b6b33a8ab31b0c6a2b15c6724a9b663127bf7f07d2e", size = 2664628, upload-time = "2026-01-24T13:10:27.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/4a/63a81e9eef922b9f26bd948b518b73704d01a8d8e83324b2f99084ab7af0/uharfbuzz-0.53.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:35ec3b600b3f63e7659792f9bd43e1ffb389d3d2aac8285f269d11efbe04787d", size = 2757384, upload-time = "2026-01-24T13:10:29.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/d2/27be1201488323d0ff0c99fb966a0522b2736f79bd5a5b7b99526fca3d98/uharfbuzz-0.53.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6f0ad2812303d2c7ccff596fd6c9d5629874f3a83f30255e11639c9b7ba4e89d", size = 1335822, upload-time = "2026-01-24T13:10:34.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/99/53e39bcd4dec5981eb70a6a76285a862c8a76b80cd52e8f40fe51adab032/uharfbuzz-0.53.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:757d9ed1841912e8f229319f335cf7dd25a2fd377e444bda9deb720617192e12", size = 1237560, upload-time = "2026-01-24T13:10:36.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/2b/04d8cde466acfe70373d4f489da5c6eab0aba07d50442dd21217cb0fd167/uharfbuzz-0.53.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d3a0b824811bd1be129356818e6cdbf0e4b056bb60aa9a5eb270bff9d21f24c", size = 1497923, upload-time = "2026-01-24T13:10:38.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/01/a250521491bc995609275e0062c552b16f437a3ce15de83250176245093e/uharfbuzz-0.53.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9211d798b2921a99b8c34e810676137f66372d3b5447765b72d969bdfa6abe6a", size = 1556794, upload-time = "2026-01-24T13:10:40.262Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ujson"
|
name = "ujson"
|
||||||
version = "5.11.0"
|
version = "5.11.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user