mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-28 21:47:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca42c27455 | ||
|
|
a626ad4627 | ||
|
|
9029b27aeb | ||
|
|
03e05e0804 | ||
|
|
2f79549f92 | ||
|
|
9e9f3f4e09 | ||
|
|
d0869bec01 |
+1
-2
@@ -302,8 +302,7 @@ The following methods are supported:
|
|||||||
- `delete`
|
- `delete`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `reprocess`
|
- `reprocess`
|
||||||
- Optional `parameters`: `{ "remote_ocr": true }` to send the documents to the
|
- No `parameters` required
|
||||||
remote OCR engine, see [Remote OCR](usage.md#remote-ocr). Defaults to false.
|
|
||||||
- `set_permissions`
|
- `set_permissions`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
|
|||||||
@@ -2066,18 +2066,6 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
Defaults to None.
|
Defaults to None.
|
||||||
|
|
||||||
#### [`PAPERLESS_REMOTE_OCR_MODE=<str>`](#PAPERLESS_REMOTE_OCR_MODE) {#PAPERLESS_REMOTE_OCR_MODE}
|
|
||||||
|
|
||||||
: Which documents are sent to the remote OCR engine.
|
|
||||||
|
|
||||||
- `always`: every document of a supported file type is sent to the remote
|
|
||||||
engine, bypassing the local OCR engine.
|
|
||||||
- `workflow_only`: documents are processed locally unless a workflow
|
|
||||||
explicitly enables remote OCR for them, letting you use the remote engine
|
|
||||||
selectively.
|
|
||||||
|
|
||||||
Defaults to "always".
|
|
||||||
|
|
||||||
## AI {#ai}
|
## AI {#ai}
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||||
|
|||||||
@@ -456,20 +456,6 @@ def score(
|
|||||||
return 10
|
return 10
|
||||||
```
|
```
|
||||||
|
|
||||||
**Remote services**
|
|
||||||
|
|
||||||
If your parser sends document content to a remote service, declare it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyCustomParser:
|
|
||||||
uses_remote_service = True
|
|
||||||
```
|
|
||||||
|
|
||||||
Paperless-ngx excludes such parsers when the document being consumed has not
|
|
||||||
been marked for remote processing, so users can keep remote OCR off by default
|
|
||||||
and enable it selectively with a workflow. Parsers that do not declare the
|
|
||||||
attribute are treated as fully local and are always considered.
|
|
||||||
|
|
||||||
**Archive and rendition flags**
|
**Archive and rendition flags**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
+1
-22
@@ -654,19 +654,6 @@ happened while it was still encrypted, that original version will likewise be mi
|
|||||||
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
|
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
|
||||||
multiple PDF types with different passwords, create separate workflows for each use case.
|
multiple PDF types with different passwords, create separate workflows for each use case.
|
||||||
|
|
||||||
##### Remote OCR {#workflow-action-remote-ocr}
|
|
||||||
|
|
||||||
"Remote OCR" actions send the document to the configured remote OCR engine instead of processing it
|
|
||||||
locally. To use remote OCR selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE)
|
|
||||||
to `workflow_only` then add this action to a workflow that matches only the documents you
|
|
||||||
want sent to the remote engine. See [Remote OCR](#remote-ocr) for the engine setup. The action only works with
|
|
||||||
a **Consumption Started** trigger.
|
|
||||||
|
|
||||||
The action takes no options, its presence is what enables remote OCR for a matching document.
|
|
||||||
|
|
||||||
If the remote engine is not configured, or does not support the document's file type, the document is
|
|
||||||
processed locally instead and a warning is written to the log.
|
|
||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
@@ -1103,19 +1090,11 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
|
|||||||
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
||||||
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
||||||
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
||||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
|
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
|
||||||
settings can be supplied as environment variables or via **Application Configuration**.
|
|
||||||
|
|
||||||
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
||||||
or page limitations (e.g. with a free tier).
|
or page limitations (e.g. with a free tier).
|
||||||
|
|
||||||
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
|
|
||||||
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
|
|
||||||
unless a [remote OCR workflow action](#workflow-action-remote-ocr) enables it for them, so you can limit the remote
|
|
||||||
engine to particular documents.
|
|
||||||
|
|
||||||
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Paperless-ngx consists of the following components:
|
Paperless-ngx consists of the following components:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ dependencies = [
|
|||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar~=2026.7.1",
|
"drf-spectacular-sidecar~=2026.7.1",
|
||||||
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.14.0",
|
"gotenberg-client~=0.14.0",
|
||||||
@@ -76,6 +77,7 @@ dependencies = [
|
|||||||
"torch~=2.13.0",
|
"torch~=2.13.0",
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
|
"whoosh-compat[tantivy]==0.1",
|
||||||
"zxing-cpp~=3.1.0",
|
"zxing-cpp~=3.1.0",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+220
-301
File diff suppressed because it is too large
Load Diff
@@ -14,48 +14,43 @@
|
|||||||
<a ngbNavLink>{{category}}</a>
|
<a ngbNavLink>{{category}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
@for (section of getCategorySections(category); track section) {
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
@if (section) {
|
@for (option of getCategoryOptions(category); track option.key) {
|
||||||
<h5 class="mt-4 mb-3">{{section}}</h5>
|
<div class="col">
|
||||||
}
|
<div class="card bg-light">
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
<div class="card-body">
|
||||||
@for (option of getCategoryOptions(category, section); track option.key) {
|
<div class="card-title d-flex align-items-center">
|
||||||
<div class="col">
|
<h6 class="mb-0">
|
||||||
<div class="card bg-light">
|
{{option.title}}
|
||||||
<div class="card-body">
|
</h6>
|
||||||
<div class="card-title d-flex align-items-center">
|
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||||
<h6 class="mb-0">
|
<i-bs name="info-circle"></i-bs>
|
||||||
{{option.title}}
|
</a>
|
||||||
</h6>
|
@if (isSet(option.key)) {
|
||||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||||
<i-bs name="info-circle"></i-bs>
|
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||||
</a>
|
</button>
|
||||||
@if (isSet(option.key)) {
|
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mb-n3">
|
|
||||||
@switch (option.type) {
|
|
||||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
|
||||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
|
||||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
|
||||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
|
||||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
|
||||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
|
||||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (option.note) {
|
|
||||||
<div class="form-text fst-italic">{{option.note}}</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-n3">
|
||||||
|
@switch (option.type) {
|
||||||
|
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||||
|
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||||
|
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||||
|
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||||
|
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (option.note) {
|
||||||
|
<div class="form-text fst-italic">{{option.note}}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
|||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import {
|
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
||||||
ConfigCategory,
|
|
||||||
ConfigSection,
|
|
||||||
OutputTypeConfig,
|
|
||||||
} from 'src/app/data/paperless-config'
|
|
||||||
import { ConfigService } from 'src/app/services/config.service'
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -162,24 +158,4 @@ describe('ConfigComponent', () => {
|
|||||||
component.resetOption('barcodes_enabled')
|
component.resetOption('barcodes_enabled')
|
||||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should group options into sections within a category, or not', () => {
|
|
||||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
|
||||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
|
||||||
expect(
|
|
||||||
component
|
|
||||||
.getCategoryOptions(ConfigCategory.OCR)
|
|
||||||
.map((option) => option.key)
|
|
||||||
).toContain('output_type')
|
|
||||||
expect(
|
|
||||||
component
|
|
||||||
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
|
|
||||||
.map((option) => option.key)
|
|
||||||
).toEqual([
|
|
||||||
'remote_ocr_engine',
|
|
||||||
'remote_ocr_api_key',
|
|
||||||
'remote_ocr_endpoint',
|
|
||||||
'remote_ocr_mode',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,20 +74,8 @@ export class ConfigComponent
|
|||||||
return Object.values(ConfigCategory)
|
return Object.values(ConfigCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategorySections(category: string): string[] {
|
getCategoryOptions(category: string): ConfigOption[] {
|
||||||
return [
|
return PaperlessConfigOptions.filter((o) => o.category === category)
|
||||||
...new Set(
|
|
||||||
PaperlessConfigOptions.filter((o) => o.category === category).map(
|
|
||||||
(o) => o.section ?? null // null means no section
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
|
|
||||||
return PaperlessConfigOptions.filter(
|
|
||||||
(o) => o.category === category && (o.section ?? null) === section
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initialConfig: PaperlessConfig
|
initialConfig: PaperlessConfig
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||||
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
|
||||||
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-3 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 header-search mx-auto">
|
<div class="col-12 header-search">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search></pngx-global-search>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
[routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
<li class="nav-item w-100 app-link">
|
<li class="nav-item w-100 app-link">
|
||||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
||||||
<span class="close flex-column justify-content-center"
|
<span class="close flex-column justify-content-center"
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
<li class="nav-item w-100 app-link">
|
<li class="nav-item w-100 app-link">
|
||||||
<button type="button" class="nav-link nav-link-action app-link w-100 text-start"
|
<button type="button" class="nav-link nav-link-action app-link w-100 text-start"
|
||||||
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
<div class="d-flex align-items-center attributes-row">
|
<div class="d-flex align-items-center attributes-row">
|
||||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
|
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
|
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
}
|
}
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
|
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -258,14 +258,14 @@
|
|||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -281,21 +281,21 @@
|
|||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
tourAnchor="tour.settings">
|
tourAnchor="tour.settings">
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
|
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
|
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
|
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||||
tourAnchor="tour.file-tasks">
|
tourAnchor="tour.file-tasks">
|
||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
||||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
||||||
@@ -317,26 +317,26 @@
|
|||||||
@if (permissionsService.isAdmin()) {
|
@if (permissionsService.isAdmin()) {
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
||||||
<div class="me-3">
|
<div class="me-3">
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
{{ versionString }}
|
{{ versionString }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -67,23 +67,6 @@
|
|||||||
main.mobile-search-hidden {
|
main.mobile-search-hidden {
|
||||||
padding-top: 56px;
|
padding-top: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-toggler {
|
|
||||||
padding-left: calc(12px - (1.5em * 2.5 / 16));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container {
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Below sm the search gets its own full-width row, so line it up with main's content edge
|
|
||||||
@media (max-width: 575.98px) {
|
|
||||||
.search-container {
|
|
||||||
padding-left: 12px;
|
|
||||||
padding-right: 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -114,7 +97,7 @@ main {
|
|||||||
|
|
||||||
li.nav-item span.badge {
|
li.nav-item span.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: -4px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,11 +141,18 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.slim {
|
||||||
|
li.nav-item span.badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-slim-toggler {
|
.sidebar-slim-toggler {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: calc(var(--pngx-sidebar-width) - 12px);
|
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||||
bottom: 16px;
|
top: 72px;
|
||||||
z-index: 996;
|
z-index: 996;
|
||||||
--bs-btn-padding-x: 0.35rem;
|
--bs-btn-padding-x: 0.35rem;
|
||||||
--bs-btn-padding-y: 0.125rem;
|
--bs-btn-padding-y: 0.125rem;
|
||||||
@@ -237,10 +227,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .nav-anchor, .sidebar .nav-label {
|
|
||||||
padding: .25rem .7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes-row {
|
.attributes-row {
|
||||||
border-radius: .55rem;
|
border-radius: .55rem;
|
||||||
margin: .1rem 0;
|
margin: .1rem 0;
|
||||||
@@ -458,7 +444,7 @@ main {
|
|||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 55rem;
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
@@ -472,7 +458,6 @@ main {
|
|||||||
|
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
grid-area: toggler;
|
grid-area: toggler;
|
||||||
color: var(--pngx-primary-text-contrast);
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,10 +278,6 @@ export class AppFrameComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarPopoversEnabled(): boolean {
|
|
||||||
return this.slimSidebarEnabled && !this.isMobileViewport()
|
|
||||||
}
|
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
this.settingsService.trackChanges()
|
this.settingsService.trackChanges()
|
||||||
return this.settingsService
|
return this.settingsService
|
||||||
|
|||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (messageBold) {
|
|
||||||
<p class="text-break"><b>{{messageBold}}</b></p>
|
|
||||||
}
|
|
||||||
@if (message) {
|
|
||||||
<p class="mb-0 text-break" [innerHTML]="message"></p>
|
|
||||||
}
|
|
||||||
@if (showRemoteOcr) {
|
|
||||||
<div class="form-check mt-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="reprocessRemoteOcr" [(ngModel)]="remoteOcr" />
|
|
||||||
<label class="form-check-label" for="reprocessRemoteOcr" i18n>Use remote OCR</label>
|
|
||||||
<div class="form-text" i18n>Sends the document to the configured remote OCR service, which may incur costs.</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
|
||||||
{{btnCaption}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { RemoteOCRModeConfig } from 'src/app/data/paperless-config'
|
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
|
||||||
import { ReprocessConfirmDialogComponent } from './reprocess-confirm-dialog.component'
|
|
||||||
|
|
||||||
describe('ReprocessConfirmDialogComponent', () => {
|
|
||||||
let component: ReprocessConfirmDialogComponent
|
|
||||||
let fixture: ComponentFixture<ReprocessConfirmDialogComponent>
|
|
||||||
let settingsService: SettingsService
|
|
||||||
|
|
||||||
const createComponent = (configured: boolean, mode: string) => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, configured)
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_MODE, mode)
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ReprocessConfirmDialogComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
fixture.detectChanges()
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
NgbActiveModal,
|
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
],
|
|
||||||
imports: [ReprocessConfirmDialogComponent],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
settingsService = TestBed.inject(SettingsService)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not request remote OCR by default', () => {
|
|
||||||
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
|
||||||
|
|
||||||
expect(component.remoteOcr).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not offer remote OCR when no engine is configured', () => {
|
|
||||||
createComponent(false, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
|
||||||
|
|
||||||
expect(component.showRemoteOcr).toBeFalsy()
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not offer remote OCR when it already handles every document', () => {
|
|
||||||
createComponent(true, RemoteOCRModeConfig.ALWAYS)
|
|
||||||
|
|
||||||
expect(component.showRemoteOcr).toBeFalsy()
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should offer remote OCR when configured and selective', () => {
|
|
||||||
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
|
||||||
|
|
||||||
expect(component.showRemoteOcr).toBeTruthy()
|
|
||||||
const checkbox = fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
|
||||||
expect(checkbox).not.toBeNull()
|
|
||||||
|
|
||||||
checkbox.click()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.remoteOcr).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-reprocess-confirm-dialog',
|
|
||||||
templateUrl: './reprocess-confirm-dialog.component.html',
|
|
||||||
imports: [FormsModule],
|
|
||||||
})
|
|
||||||
export class ReprocessConfirmDialogComponent extends ConfirmDialogComponent {
|
|
||||||
private settings = inject(SettingsService)
|
|
||||||
|
|
||||||
remoteOcr: boolean = false
|
|
||||||
|
|
||||||
public get showRemoteOcr(): boolean {
|
|
||||||
// Hidden when it is not configured, or when it already handles every document anyway.
|
|
||||||
return this.settings.remoteOCRIsSelectable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-7
@@ -455,13 +455,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@case (WorkflowActionType.RemoteOcr) {
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<p class="text-muted small" i18n>The document will be sent to the configured remote OCR service. May incur costs.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
+2
-105
@@ -29,7 +29,6 @@ import {
|
|||||||
DocumentSource,
|
DocumentSource,
|
||||||
WorkflowTriggerType,
|
WorkflowTriggerType,
|
||||||
} from 'src/app/data/workflow-trigger'
|
} from 'src/app/data/workflow-trigger'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
@@ -225,12 +224,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
).toEqual('Document Added')
|
).toEqual('Document Added')
|
||||||
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
||||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||||
// Remote OCR is absent until the workflow has a consumption trigger
|
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
|
||||||
expect(component.actionTypeOptions).toEqual(
|
|
||||||
WORKFLOW_ACTION_OPTIONS.filter(
|
|
||||||
(a) => a.id !== WorkflowActionType.RemoteOcr
|
|
||||||
)
|
|
||||||
)
|
|
||||||
expect(
|
expect(
|
||||||
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
||||||
).toEqual('Assignment')
|
).toEqual('Assignment')
|
||||||
@@ -243,104 +237,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.actionTypeOptions).toEqual(
|
expect(component.actionTypeOptions).toEqual(
|
||||||
WORKFLOW_ACTION_OPTIONS.filter(
|
WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email)
|
||||||
(a) =>
|
|
||||||
a.id !== WorkflowActionType.Email &&
|
|
||||||
a.id !== WorkflowActionType.RemoteOcr
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should offer remote OCR only for consumption workflows', () => {
|
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
|
||||||
|
|
||||||
// A consumption trigger makes the action reachable
|
|
||||||
component.object = {
|
|
||||||
name: 'Workflow 1',
|
|
||||||
order: 0,
|
|
||||||
enabled: true,
|
|
||||||
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
|
||||||
actions: [],
|
|
||||||
} as Workflow
|
|
||||||
component.ngOnInit()
|
|
||||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
|
||||||
WorkflowActionType.RemoteOcr
|
|
||||||
)
|
|
||||||
|
|
||||||
// Any other trigger type runs after the document has been parsed
|
|
||||||
component.object = {
|
|
||||||
name: 'Workflow 2',
|
|
||||||
order: 0,
|
|
||||||
enabled: true,
|
|
||||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
|
||||||
actions: [],
|
|
||||||
} as Workflow
|
|
||||||
component.ngOnInit()
|
|
||||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
|
||||||
WorkflowActionType.RemoteOcr
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
|
||||||
component.ngOnInit()
|
|
||||||
|
|
||||||
// Nothing for the action to apply to yet
|
|
||||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
|
||||||
WorkflowActionType.RemoteOcr
|
|
||||||
)
|
|
||||||
|
|
||||||
// addTrigger creates the form field with emitEvent false, so the options
|
|
||||||
// have to be computed on read rather than cached from valueChanges
|
|
||||||
component.addTrigger()
|
|
||||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
|
||||||
WorkflowActionType.RemoteOcr
|
|
||||||
)
|
|
||||||
|
|
||||||
// Switching that trigger to a type that runs after parsing removes it
|
|
||||||
component.triggerFields
|
|
||||||
.at(0)
|
|
||||||
.get('type')
|
|
||||||
.setValue(WorkflowTriggerType.DocumentAdded)
|
|
||||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
|
||||||
WorkflowActionType.RemoteOcr
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should keep remote OCR listed when an action already uses it', () => {
|
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
|
||||||
|
|
||||||
// Otherwise changing the trigger would silently blank the selection
|
|
||||||
component.object = {
|
|
||||||
name: 'Workflow 1',
|
|
||||||
order: 0,
|
|
||||||
enabled: true,
|
|
||||||
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
|
||||||
actions: [{ type: WorkflowActionType.RemoteOcr }],
|
|
||||||
} as Workflow
|
|
||||||
component.ngOnInit()
|
|
||||||
|
|
||||||
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
|
||||||
WorkflowActionType.RemoteOcr
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not offer remote OCR when no engine is configured', () => {
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
|
||||||
|
|
||||||
component.object = {
|
|
||||||
name: 'Workflow 1',
|
|
||||||
order: 0,
|
|
||||||
enabled: true,
|
|
||||||
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
|
||||||
actions: [],
|
|
||||||
} as Workflow
|
|
||||||
component.ngOnInit()
|
|
||||||
|
|
||||||
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
|
||||||
WorkflowActionType.RemoteOcr
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+10
-40
@@ -148,10 +148,6 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.MoveToTrash,
|
id: WorkflowActionType.MoveToTrash,
|
||||||
name: $localize`Move to trash`,
|
name: $localize`Move to trash`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: WorkflowActionType.RemoteOcr,
|
|
||||||
name: $localize`Remote OCR`,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
@@ -508,6 +504,8 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
|
readonly allowedActionTypes = signal([])
|
||||||
|
|
||||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||||
FormArray,
|
FormArray,
|
||||||
TriggerFilterOption[]
|
TriggerFilterOption[]
|
||||||
@@ -550,40 +548,13 @@ export class WorkflowEditDialogComponent
|
|||||||
this.checkRemovalActionFields.bind(this)
|
this.checkRemovalActionFields.bind(this)
|
||||||
)
|
)
|
||||||
this.checkRemovalActionFields(this.objectForm.value)
|
this.checkRemovalActionFields(this.objectForm.value)
|
||||||
}
|
this.allowedActionTypes.set(
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||||
private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
|
? WORKFLOW_ACTION_OPTIONS
|
||||||
|
: WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
private getAllowedActionTypes() {
|
(a) => a.id !== WorkflowActionType.Email
|
||||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
)
|
||||||
|
)
|
||||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
|
||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote OCR is decided before the document is parsed, so it is only
|
|
||||||
// offered for workflows that run at consumption.
|
|
||||||
const formWorkflow: Workflow = this.objectForm?.value
|
|
||||||
const remoteOcrUsable =
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
|
||||||
(formWorkflow?.triggers?.some(
|
|
||||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
|
||||||
) ||
|
|
||||||
formWorkflow?.actions?.some(
|
|
||||||
(action) => action.type === WorkflowActionType.RemoteOcr
|
|
||||||
))
|
|
||||||
if (!remoteOcrUsable) {
|
|
||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.allowedActionTypes?.length === allowed.length &&
|
|
||||||
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
|
||||||
) {
|
|
||||||
return this.allowedActionTypes
|
|
||||||
}
|
|
||||||
this.allowedActionTypes = allowed
|
|
||||||
return allowed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkRemovalActionFields(formWorkflow: Workflow) {
|
private checkRemovalActionFields(formWorkflow: Workflow) {
|
||||||
@@ -1308,8 +1279,7 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
this.settingsService.trackChanges()
|
this.settingsService.trackChanges()
|
||||||
// Computed on read rather than cached
|
return this.allowedActionTypes()
|
||||||
return this.getAllowedActionTypes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="row pt-3 pb-2 align-items-center">
|
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||||
<div class="col-md text-truncate">
|
<div class="col-md text-truncate">
|
||||||
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
|
||||||
<span class="text-truncate">{{title()}}</span>
|
<span class="text-truncate">{{title()}}</span>
|
||||||
@if (id()) {
|
@if (id()) {
|
||||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@if (subTitle()) {
|
@if (subTitle()) {
|
||||||
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||||
}
|
}
|
||||||
@if (info()) {
|
@if (info()) {
|
||||||
<button class="btn btn-sm btn-link text-muted p-0 ms-2 p-md-2 ms-md-0" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||||
<i-bs name="question-circle"></i-bs>
|
<i-bs name="question-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #infoPopover>
|
<ng-template #infoPopover>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
h3 {
|
h3 {
|
||||||
min-height: calc(1.325rem + 0.9vw);
|
min-height: calc(1.325rem + 0.9vw);
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -8,16 +7,6 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the subtitle onto its own and shrink it
|
|
||||||
@media (max-width: 767.98px) {
|
|
||||||
h3 > .page-subtitle {
|
|
||||||
flex: 0 0 100%;
|
|
||||||
margin-top: 0 !important;
|
|
||||||
font-size: .7rem;
|
|
||||||
line-height: 1.3 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: .35rem;
|
margin-bottom: .35rem;
|
||||||
|
|||||||
@@ -963,24 +963,12 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.reprocess()
|
component.reprocess()
|
||||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
openModal.componentInstance.confirmClicked.next()
|
openModal.componentInstance.confirmClicked.next()
|
||||||
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
|
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] })
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should pass remote OCR choice when reprocessing', () => {
|
|
||||||
initNormally()
|
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
|
||||||
reprocessSpy.mockReturnValue(of(true))
|
|
||||||
let openModal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
|
||||||
component.reprocess()
|
|
||||||
openModal.componentInstance.remoteOcr = true
|
|
||||||
openModal.componentInstance.confirmClicked.next()
|
|
||||||
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error if redo ocr call fails', () => {
|
it('should show error if redo ocr call fails', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
|||||||
import * as UTIF from 'utif'
|
import * as UTIF from 'utif'
|
||||||
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
@@ -1399,7 +1398,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reprocess() {
|
reprocess() {
|
||||||
let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Reprocess confirm`
|
modal.componentInstance.title = $localize`Reprocess confirm`
|
||||||
@@ -1410,10 +1409,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.reprocessDocuments(
|
.reprocessDocuments({ documents: [this.document().id] })
|
||||||
{ documents: [this.document().id] },
|
|
||||||
modal.componentInstance.remoteOcr
|
|
||||||
)
|
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
|
|||||||
@@ -1122,7 +1122,6 @@ describe('BulkEditorComponent', () => {
|
|||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
remote_ocr: false,
|
|
||||||
})
|
})
|
||||||
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&include_selection_data=true`
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import { flattenTags } from 'src/app/utils/flatten-tags'
|
|||||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
@@ -917,7 +916,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reprocessSelected() {
|
reprocessSelected() {
|
||||||
let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Reprocess confirm`
|
modal.componentInstance.title = $localize`Reprocess confirm`
|
||||||
@@ -931,10 +930,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.reprocessDocuments(
|
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
||||||
this.getSelectionQuery(),
|
|
||||||
modal.componentInstance.remoteOcr
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,10 +54,6 @@ export const ConfigCategory = {
|
|||||||
AI: $localize`AI Settings`,
|
AI: $localize`AI Settings`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfigSection = {
|
|
||||||
RemoteOCR: $localize`Remote OCR`,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LLMEmbeddingBackendConfig = {
|
export const LLMEmbeddingBackendConfig = {
|
||||||
OPENAI_LIKE: 'openai-like',
|
OPENAI_LIKE: 'openai-like',
|
||||||
HUGGINGFACE: 'huggingface',
|
HUGGINGFACE: 'huggingface',
|
||||||
@@ -69,15 +65,6 @@ export const LLMBackendConfig = {
|
|||||||
OLLAMA: 'ollama',
|
OLLAMA: 'ollama',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RemoteOCREngineConfig = {
|
|
||||||
AZURE_AI: 'azureai',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RemoteOCRModeConfig = {
|
|
||||||
ALWAYS: 'always',
|
|
||||||
WORKFLOW_ONLY: 'workflow_only',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigOption {
|
export interface ConfigOption {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
@@ -85,7 +72,6 @@ export interface ConfigOption {
|
|||||||
choices?: Array<{ id: string; name: string }>
|
choices?: Array<{ id: string; name: string }>
|
||||||
config_key?: string
|
config_key?: string
|
||||||
category: string
|
category: string
|
||||||
section?: string
|
|
||||||
note?: string
|
note?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,43 +181,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
||||||
category: ConfigCategory.OCR,
|
category: ConfigCategory.OCR,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'remote_ocr_engine',
|
|
||||||
title: $localize`Remote OCR Engine`,
|
|
||||||
type: ConfigOptionType.Select,
|
|
||||||
choices: mapToItems(RemoteOCREngineConfig),
|
|
||||||
config_key: 'PAPERLESS_REMOTE_OCR_ENGINE',
|
|
||||||
category: ConfigCategory.OCR,
|
|
||||||
section: ConfigSection.RemoteOCR,
|
|
||||||
note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'remote_ocr_api_key',
|
|
||||||
title: $localize`Remote OCR API Key`,
|
|
||||||
type: ConfigOptionType.Password,
|
|
||||||
config_key: 'PAPERLESS_REMOTE_OCR_API_KEY',
|
|
||||||
category: ConfigCategory.OCR,
|
|
||||||
section: ConfigSection.RemoteOCR,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'remote_ocr_endpoint',
|
|
||||||
title: $localize`Remote OCR Endpoint`,
|
|
||||||
type: ConfigOptionType.String,
|
|
||||||
config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT',
|
|
||||||
category: ConfigCategory.OCR,
|
|
||||||
section: ConfigSection.RemoteOCR,
|
|
||||||
note: $localize`Required when using the Azure AI engine.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'remote_ocr_mode',
|
|
||||||
title: $localize`Remote OCR Mode`,
|
|
||||||
type: ConfigOptionType.Select,
|
|
||||||
choices: mapToItems(RemoteOCRModeConfig),
|
|
||||||
config_key: 'PAPERLESS_REMOTE_OCR_MODE',
|
|
||||||
category: ConfigCategory.OCR,
|
|
||||||
section: ConfigSection.RemoteOCR,
|
|
||||||
note: $localize`Which documents are sent to the remote engine. Use 'workflow_only' to keep remote OCR off unless a workflow enables it for a document.`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'app_logo',
|
key: 'app_logo',
|
||||||
title: $localize`Application Logo`,
|
title: $localize`Application Logo`,
|
||||||
@@ -449,10 +398,6 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
barcode_enable_tag: boolean
|
barcode_enable_tag: boolean
|
||||||
barcode_tag_mapping: object
|
barcode_tag_mapping: object
|
||||||
barcode_tag_split: boolean
|
barcode_tag_split: boolean
|
||||||
remote_ocr_engine: string
|
|
||||||
remote_ocr_api_key: string
|
|
||||||
remote_ocr_endpoint: string
|
|
||||||
remote_ocr_mode: string
|
|
||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
llm_embedding_backend: string
|
llm_embedding_backend: string
|
||||||
llm_embedding_model: string
|
llm_embedding_model: string
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
|
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
|
||||||
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
|
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
|
||||||
import { RemoteOCRModeConfig } from './paperless-config'
|
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
@@ -95,8 +94,6 @@ export const SETTINGS_KEYS = {
|
|||||||
OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
|
OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
|
||||||
EMAIL_ENABLED: 'email_enabled',
|
EMAIL_ENABLED: 'email_enabled',
|
||||||
AI_ENABLED: 'ai_enabled',
|
AI_ENABLED: 'ai_enabled',
|
||||||
REMOTE_OCR_CONFIGURED: 'remote_ocr:configured',
|
|
||||||
REMOTE_OCR_MODE: 'remote_ocr:mode',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SETTINGS: UiSetting[] = [
|
export const SETTINGS: UiSetting[] = [
|
||||||
@@ -350,14 +347,4 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
default: PdfEditorEditMode.Create,
|
default: PdfEditorEditMode.Create,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.REMOTE_OCR_CONFIGURED,
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.REMOTE_OCR_MODE,
|
|
||||||
type: 'string',
|
|
||||||
default: RemoteOCRModeConfig.ALWAYS,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export enum WorkflowActionType {
|
|||||||
Webhook = 4,
|
Webhook = 4,
|
||||||
PasswordRemoval = 5,
|
PasswordRemoval = 5,
|
||||||
MoveToTrash = 6,
|
MoveToTrash = 6,
|
||||||
RemoteOcr = 7,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowActionEmail extends ObjectWithId {
|
export interface WorkflowActionEmail extends ObjectWithId {
|
||||||
|
|||||||
@@ -284,21 +284,6 @@ describe(`DocumentService`, () => {
|
|||||||
expect(req.request.method).toEqual('POST')
|
expect(req.request.method).toEqual('POST')
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: ids,
|
documents: ids,
|
||||||
remote_ocr: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should request remote OCR when reprocessing with it enabled', () => {
|
|
||||||
const ids = [1, 2, 3]
|
|
||||||
subscription = service
|
|
||||||
.reprocessDocuments({ documents: ids }, true)
|
|
||||||
.subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/reprocess/`
|
|
||||||
)
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
remote_ocr: true,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -349,13 +349,9 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
reprocessDocuments(
|
reprocessDocuments(selection: DocumentSelectionQuery) {
|
||||||
selection: DocumentSelectionQuery,
|
|
||||||
remoteOcr: boolean = false
|
|
||||||
) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
||||||
...selection,
|
...selection,
|
||||||
remote_ocr: remoteOcr,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { environment } from 'src/environments/environment'
|
|||||||
import { CustomFieldDataType } from '../data/custom-field'
|
import { CustomFieldDataType } from '../data/custom-field'
|
||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
|
||||||
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
||||||
import { PermissionsService } from './permissions.service'
|
import { PermissionsService } from './permissions.service'
|
||||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||||
@@ -435,26 +434,4 @@ describe('SettingsService', () => {
|
|||||||
).name
|
).name
|
||||||
).toEqual(customFields[0].name)
|
).toEqual(customFields[0].name)
|
||||||
})
|
})
|
||||||
it('should offer remote OCR only when configured and selective', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, false)
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.REMOTE_OCR_MODE,
|
|
||||||
RemoteOCRModeConfig.WORKFLOW_ONLY
|
|
||||||
)
|
|
||||||
expect(settingsService.remoteOCRIsSelectable).toBeFalsy()
|
|
||||||
|
|
||||||
// configured, but already handling every document
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, true)
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.REMOTE_OCR_MODE,
|
|
||||||
RemoteOCRModeConfig.ALWAYS
|
|
||||||
)
|
|
||||||
expect(settingsService.remoteOCRIsSelectable).toBeFalsy()
|
|
||||||
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.REMOTE_OCR_MODE,
|
|
||||||
RemoteOCRModeConfig.WORKFLOW_ONLY
|
|
||||||
)
|
|
||||||
expect(settingsService.remoteOCRIsSelectable).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from 'src/app/utils/color'
|
} from 'src/app/utils/color'
|
||||||
import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment'
|
import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment'
|
||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import {
|
import {
|
||||||
PAPERLESS_GREEN_HEX,
|
PAPERLESS_GREEN_HEX,
|
||||||
@@ -688,17 +687,6 @@ export class SettingsService {
|
|||||||
return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)
|
return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Offering remote OCR as a choice only makes sense when an engine
|
|
||||||
* is configured but is not already handling every document.
|
|
||||||
*/
|
|
||||||
get remoteOCRIsSelectable(): boolean {
|
|
||||||
return (
|
|
||||||
this.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
|
||||||
this.get(SETTINGS_KEYS.REMOTE_OCR_MODE) !== RemoteOCRModeConfig.ALWAYS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
offerTour(): boolean {
|
offerTour(): boolean {
|
||||||
return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE)
|
return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ import {
|
|||||||
journalBookmarkFill,
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
list,
|
|
||||||
listNested,
|
listNested,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
@@ -366,7 +365,6 @@ const icons = {
|
|||||||
journalBookmarkFill,
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
list,
|
|
||||||
listNested,
|
listNested,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
|
|||||||
@@ -536,7 +536,6 @@ ul.pagination {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-underline {
|
.nav-underline {
|
||||||
gap: calc(0.5 * var(--bs-nav-underline-gap)) var(--bs-nav-underline-gap);
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
&.active, &:hover, &:focus {
|
&.active, &:hover, &:focus {
|
||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
|
|||||||
+31
-54
@@ -27,7 +27,7 @@ from documents.models import DocumentType
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
@@ -305,49 +305,33 @@ def modify_custom_fields(
|
|||||||
else [(field, None) for field in add_custom_fields]
|
else [(field, None) for field in add_custom_fields]
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_fields_by_id: dict[int, CustomField] = {
|
custom_fields = CustomField.objects.filter(
|
||||||
cf.id: cf
|
id__in=[int(field) for field, _ in add_custom_fields],
|
||||||
for cf in CustomField.objects.filter(
|
).distinct()
|
||||||
id__in=[int(field) for field, _ in add_custom_fields],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
# Deferred, not `.only()`: these objects get cached onto the FK
|
|
||||||
# descriptor of newly-created CustomFieldInstance rows below, and
|
|
||||||
# downstream post_save receivers (e.g. the filename-generation signal)
|
|
||||||
# touch other Document fields -- `.only("pk")` would just turn that into
|
|
||||||
# a deferred-field reload per document, trading one N+1 for another.
|
|
||||||
# `content` is the one field guaranteed to be both large (full OCR text)
|
|
||||||
# and unused by anything this function or its receivers touch.
|
|
||||||
docs_by_id: dict[int, Document] = {
|
|
||||||
doc.id: doc
|
|
||||||
for doc in Document.objects.filter(id__in=affected_docs).defer("content")
|
|
||||||
}
|
|
||||||
for field_id, value in add_custom_fields:
|
for field_id, value in add_custom_fields:
|
||||||
custom_field = custom_fields_by_id[field_id]
|
|
||||||
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
|
||||||
custom_field.data_type
|
|
||||||
]
|
|
||||||
for doc_id in affected_docs:
|
for doc_id in affected_docs:
|
||||||
defaults = {value_field: value}
|
defaults = {}
|
||||||
if (
|
custom_field = custom_fields.get(id=field_id)
|
||||||
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
if custom_field:
|
||||||
and value
|
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
||||||
and doc_id in value
|
custom_field.data_type
|
||||||
):
|
]
|
||||||
# Prevent self-linking
|
defaults[value_field] = value
|
||||||
continue
|
if (
|
||||||
# Pass the already-resolved objects, not bare ids: this caches
|
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
# them on the FK descriptor of any newly-created instance, so a
|
and value
|
||||||
# later `.field`/`.document` access (e.g. auditlog's post_save
|
and doc_id in value
|
||||||
# receiver calling `str(instance)`, which touches `.field.name`)
|
):
|
||||||
# doesn't trigger its own per-instance re-fetch.
|
# Prevent self-linking
|
||||||
|
continue
|
||||||
CustomFieldInstance.objects.update_or_create(
|
CustomFieldInstance.objects.update_or_create(
|
||||||
document=docs_by_id[doc_id],
|
document_id=doc_id,
|
||||||
field=custom_field,
|
field_id=field_id,
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
reflect_doclinks(docs_by_id[doc_id], custom_field, value)
|
doc = Document.objects.get(id=doc_id)
|
||||||
|
reflect_doclinks(doc, custom_field, value)
|
||||||
|
|
||||||
# For doc link fields that are being removed, remove symmetrical links
|
# For doc link fields that are being removed, remove symmetrical links
|
||||||
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
||||||
@@ -355,10 +339,12 @@ def modify_custom_fields(
|
|||||||
field__id__in=remove_custom_fields,
|
field__id__in=remove_custom_fields,
|
||||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
value_document_ids__isnull=False,
|
value_document_ids__isnull=False,
|
||||||
).select_related("field"):
|
):
|
||||||
for target_doc_id in doclink_being_removed_instance.value:
|
for target_doc_id in doclink_being_removed_instance.value:
|
||||||
remove_doclink(
|
remove_doclink(
|
||||||
document=docs_by_id[doclink_being_removed_instance.document_id],
|
document=Document.objects.get(
|
||||||
|
id=doclink_being_removed_instance.document.id,
|
||||||
|
),
|
||||||
field=doclink_being_removed_instance.field,
|
field=doclink_being_removed_instance.field,
|
||||||
target_doc_id=target_doc_id,
|
target_doc_id=target_doc_id,
|
||||||
)
|
)
|
||||||
@@ -413,16 +399,10 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
def reprocess(doc_ids: list[int]) -> Literal["OK"]:
|
||||||
"""
|
|
||||||
Re-run parsing for the given documents.
|
|
||||||
|
|
||||||
Consumption workflows do not run here, so ``remote_ocr`` is how the user
|
|
||||||
asks for the remote engine when it is not configured to handle everything.
|
|
||||||
"""
|
|
||||||
for document_id in doc_ids:
|
for document_id in doc_ids:
|
||||||
update_document_content_maybe_archive_file.apply_async(
|
update_document_content_maybe_archive_file.apply_async(
|
||||||
kwargs={"document_id": document_id, "remote_ocr": remote_ocr},
|
kwargs={"document_id": document_id},
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -444,13 +424,10 @@ def set_permissions(
|
|||||||
else:
|
else:
|
||||||
qs.update(owner=owner)
|
qs.update(owner=owner)
|
||||||
|
|
||||||
|
for doc in qs:
|
||||||
|
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
|
||||||
|
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
set_permissions_for_objects(
|
|
||||||
permissions=set_permissions,
|
|
||||||
model=Document,
|
|
||||||
pks=affected_docs,
|
|
||||||
merge=merge,
|
|
||||||
)
|
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
bulk_update_documents.apply_async(
|
||||||
kwargs={"document_ids": affected_docs},
|
kwargs={"document_ids": affected_docs},
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ 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.config import OcrConfig
|
from paperless.config import OcrConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
|
||||||
from paperless.models import ArchiveFileGenerationChoices
|
from paperless.models import ArchiveFileGenerationChoices
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
from paperless.parsers import ParserProtocol
|
from paperless.parsers import ParserProtocol
|
||||||
@@ -452,19 +451,12 @@ class ConsumerPlugin(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(f"Error attempting to clean PDF: {e}")
|
self.log.error(f"Error attempting to clean PDF: {e}")
|
||||||
|
|
||||||
# Workflows have already run at this point, so the metadata knows
|
|
||||||
# whether this document was singled out for remote OCR
|
|
||||||
allow_remote = (
|
|
||||||
self.metadata.remote_ocr or RemoteOCRConfig().remote_ocr_by_default
|
|
||||||
)
|
|
||||||
|
|
||||||
# Based on the mime type, get the parser for that type
|
# Based on the mime type, get the parser for that type
|
||||||
parser_class: type[ParserProtocol] | None = (
|
parser_class: type[ParserProtocol] | None = (
|
||||||
get_parser_registry().get_parser_for_file(
|
get_parser_registry().get_parser_for_file(
|
||||||
mime_type,
|
mime_type,
|
||||||
self.filename,
|
self.filename,
|
||||||
self.working_copy,
|
self.working_copy,
|
||||||
allow_remote=allow_remote,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not parser_class:
|
if not parser_class:
|
||||||
@@ -473,16 +465,6 @@ class ConsumerPlugin(
|
|||||||
f"Unsupported mime type {mime_type}",
|
f"Unsupported mime type {mime_type}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.metadata.remote_ocr and not getattr(
|
|
||||||
parser_class,
|
|
||||||
"uses_remote_service",
|
|
||||||
False,
|
|
||||||
):
|
|
||||||
self.log.warning(
|
|
||||||
"Remote OCR was requested for this document but no remote "
|
|
||||||
"parser is available for it, processing locally instead.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify all listeners that we're going to do some work.
|
# Notify all listeners that we're going to do some work.
|
||||||
|
|
||||||
document_consumption_started.send(
|
document_consumption_started.send(
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ class DocumentMetadataOverrides:
|
|||||||
skip_asn_if_exists: bool = False
|
skip_asn_if_exists: bool = False
|
||||||
version_label: str | None = None
|
version_label: str | None = None
|
||||||
actor_id: int | None = None
|
actor_id: int | None = None
|
||||||
remote_ocr: bool = False
|
|
||||||
|
|
||||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||||
"""
|
"""
|
||||||
@@ -58,8 +57,6 @@ class DocumentMetadataOverrides:
|
|||||||
self.actor_id = other.actor_id
|
self.actor_id = other.actor_id
|
||||||
if other.skip_asn_if_exists:
|
if other.skip_asn_if_exists:
|
||||||
self.skip_asn_if_exists = True
|
self.skip_asn_if_exists = True
|
||||||
if other.remote_ocr:
|
|
||||||
self.remote_ocr = True
|
|
||||||
if other.version_label is not None:
|
if other.version_label is not None:
|
||||||
self.version_label = other.version_label
|
self.version_label = other.version_label
|
||||||
|
|
||||||
@@ -129,7 +126,7 @@ class DocumentMetadataOverrides:
|
|||||||
)
|
)
|
||||||
overrides.custom_fields = {
|
overrides.custom_fields = {
|
||||||
custom_field.field.id: custom_field.value
|
custom_field.field.id: custom_field.value
|
||||||
for custom_field in doc.custom_fields.select_related("field").all()
|
for custom_field in doc.custom_fields.all()
|
||||||
}
|
}
|
||||||
|
|
||||||
groups_with_perms = get_groups_with_perms(
|
groups_with_perms = get_groups_with_perms(
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# Generated by Django 5.2.16 on 2026-08-10 17:27
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("documents", "0023_savedview_icon"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="workflowaction",
|
|
||||||
name="type",
|
|
||||||
field=models.PositiveSmallIntegerField(
|
|
||||||
choices=[
|
|
||||||
(1, "Assignment"),
|
|
||||||
(2, "Removal"),
|
|
||||||
(3, "Email"),
|
|
||||||
(4, "Webhook"),
|
|
||||||
(5, "Password removal"),
|
|
||||||
(6, "Move to trash"),
|
|
||||||
(7, "Remote OCR"),
|
|
||||||
],
|
|
||||||
default=1,
|
|
||||||
verbose_name="Workflow Action Type",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1670,10 +1670,6 @@ class WorkflowAction(models.Model):
|
|||||||
6,
|
6,
|
||||||
_("Move to trash"),
|
_("Move to trash"),
|
||||||
)
|
)
|
||||||
REMOTE_OCR = (
|
|
||||||
7,
|
|
||||||
_("Remote OCR"),
|
|
||||||
)
|
|
||||||
|
|
||||||
type = models.PositiveSmallIntegerField(
|
type = models.PositiveSmallIntegerField(
|
||||||
_("Workflow Action Type"),
|
_("Workflow Action Type"),
|
||||||
|
|||||||
@@ -173,182 +173,6 @@ def set_permissions_for_object(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
|
||||||
"""
|
|
||||||
Resolves `codenames` to Permission rows, raising like the single-object
|
|
||||||
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
|
||||||
if any codename doesn't exist -- e.g. a client-supplied action name that
|
|
||||||
was never validated (BulkEditObjectsSerializer._validate_permissions
|
|
||||||
calls validate_set_permissions() only for its side-effecting id checks
|
|
||||||
and discards the filtered dict it returns, so an unrecognized action key
|
|
||||||
reaches this function as-is). A plain `.filter()` with no existence
|
|
||||||
check would otherwise silently build zero rows and no-op instead of
|
|
||||||
reporting the bad input.
|
|
||||||
"""
|
|
||||||
permission_objs = list(
|
|
||||||
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
|
||||||
)
|
|
||||||
missing = codenames - {p.codename for p in permission_objs}
|
|
||||||
if missing:
|
|
||||||
raise Permission.DoesNotExist(
|
|
||||||
f"Permission matching query does not exist for codename(s): "
|
|
||||||
f"{', '.join(sorted(missing))}",
|
|
||||||
)
|
|
||||||
return permission_objs
|
|
||||||
|
|
||||||
|
|
||||||
# Target number of permission rows to build in Python before handing them to
|
|
||||||
# bulk_create -- keeps peak memory bounded for a large "apply to all" call,
|
|
||||||
# independent of bulk_create's own batch_size (which only caps the size of
|
|
||||||
# each INSERT statement, not how many row objects exist in memory at once).
|
|
||||||
_PERMISSION_ROW_CHUNK_SIZE = 5000
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_bulk_permission_entry(
|
|
||||||
*,
|
|
||||||
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
|
||||||
identity_model: type[User] | type[Group],
|
|
||||||
identity_field: str,
|
|
||||||
ids: list[int],
|
|
||||||
codename: str,
|
|
||||||
permission_objs: list[Permission],
|
|
||||||
ctype: ContentType,
|
|
||||||
object_pks: list[str],
|
|
||||||
merge: bool,
|
|
||||||
) -> None:
|
|
||||||
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
|
||||||
# so avoid fetching full User/Group rows for identities that may not
|
|
||||||
# even end up being granted anything new.
|
|
||||||
add_ids = set(
|
|
||||||
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not merge:
|
|
||||||
existing_ids = set(
|
|
||||||
perm_model.objects.filter(
|
|
||||||
content_type=ctype,
|
|
||||||
object_pk__in=object_pks,
|
|
||||||
permission__codename=codename,
|
|
||||||
).values_list(f"{identity_field}_id", flat=True),
|
|
||||||
)
|
|
||||||
remove_ids = existing_ids - add_ids
|
|
||||||
if remove_ids:
|
|
||||||
perm_model.objects.filter(
|
|
||||||
content_type=ctype,
|
|
||||||
object_pk__in=object_pks,
|
|
||||||
permission__codename=codename,
|
|
||||||
**{f"{identity_field}_id__in": remove_ids},
|
|
||||||
).delete()
|
|
||||||
|
|
||||||
if not add_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
rows_per_pk = len(permission_objs) * len(add_ids)
|
|
||||||
pks_per_chunk = max(1, _PERMISSION_ROW_CHUNK_SIZE // rows_per_pk)
|
|
||||||
for start in range(0, len(object_pks), pks_per_chunk):
|
|
||||||
pk_chunk = object_pks[start : start + pks_per_chunk]
|
|
||||||
rows = [
|
|
||||||
perm_model(
|
|
||||||
content_type=ctype,
|
|
||||||
object_pk=pk,
|
|
||||||
permission=permission_obj,
|
|
||||||
**{f"{identity_field}_id": identity_id},
|
|
||||||
)
|
|
||||||
for permission_obj in permission_objs
|
|
||||||
for pk in pk_chunk
|
|
||||||
for identity_id in add_ids
|
|
||||||
]
|
|
||||||
# ignore_conflicts skips only rows that already exist as an exact
|
|
||||||
# (identity, permission, object) match -- the same de-dup the
|
|
||||||
# underlying (user|group, permission, object_pk) unique constraint
|
|
||||||
# already enforces for the single-object assign_perm() this
|
|
||||||
# replaces, so it doesn't change what counts as "already granted".
|
|
||||||
# batch_size caps how many rows go into a single INSERT so a huge
|
|
||||||
# chunk doesn't build one enormous statement.
|
|
||||||
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
|
||||||
|
|
||||||
|
|
||||||
def set_permissions_for_objects(
|
|
||||||
permissions: dict,
|
|
||||||
model: type[Model],
|
|
||||||
pks: QuerySet | list,
|
|
||||||
*,
|
|
||||||
merge: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Bulk equivalent of set_permissions_for_object: applies the same
|
|
||||||
permission changes to every object identified by `pks` at once.
|
|
||||||
|
|
||||||
Takes a model + pks (rather than model instances) deliberately -- the
|
|
||||||
permission rows built below only ever need `pk`, `content_type`, and
|
|
||||||
identity ids, so callers shouldn't have to fetch full rows (with every
|
|
||||||
other field) just to hand them to this function.
|
|
||||||
|
|
||||||
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
|
||||||
passing a list as the object routes to bulk_assign_perm, which skips
|
|
||||||
creating a direct permission row for anyone who already has the
|
|
||||||
permission via ANY group membership (it checks
|
|
||||||
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
|
||||||
unlike the single-object assign_perm this replaces, which always
|
|
||||||
ensures a direct row via get_or_create regardless of group-derived
|
|
||||||
access. Losing that guarantee would mean a later revocation of the
|
|
||||||
group's grant silently strips access an admin explicitly asked to be
|
|
||||||
direct. Bulk-creating rows straight against the permission models
|
|
||||||
instead (see _apply_bulk_permission_entry) preserves the original
|
|
||||||
always-create-a-direct-row semantics while still batching every object
|
|
||||||
and every identity into one query per action, rather than one query per
|
|
||||||
(object, user) pair.
|
|
||||||
"""
|
|
||||||
object_pks = [str(pk) for pk in pks]
|
|
||||||
if not object_pks:
|
|
||||||
return
|
|
||||||
|
|
||||||
model_name = model.__name__.lower()
|
|
||||||
ctype = ContentType.objects.get_for_model(model)
|
|
||||||
|
|
||||||
for action, entry in permissions.items():
|
|
||||||
codename = f"{action}_{model_name}"
|
|
||||||
implied_codenames = {codename}
|
|
||||||
if action == "change":
|
|
||||||
# change gives view too
|
|
||||||
implied_codenames.add(f"view_{model_name}")
|
|
||||||
|
|
||||||
# Resolved once per action (not once per users/groups branch) and
|
|
||||||
# shared between both below -- also where an unrecognized action
|
|
||||||
# name (see _resolve_permissions) is caught.
|
|
||||||
permission_objs = (
|
|
||||||
_resolve_permissions(implied_codenames, ctype)
|
|
||||||
if "users" in entry or "groups" in entry
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
|
|
||||||
if "users" in entry:
|
|
||||||
_apply_bulk_permission_entry(
|
|
||||||
perm_model=UserObjectPermission,
|
|
||||||
identity_model=User,
|
|
||||||
identity_field="user",
|
|
||||||
ids=entry["users"],
|
|
||||||
codename=codename,
|
|
||||||
permission_objs=permission_objs,
|
|
||||||
ctype=ctype,
|
|
||||||
object_pks=object_pks,
|
|
||||||
merge=merge,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "groups" in entry:
|
|
||||||
_apply_bulk_permission_entry(
|
|
||||||
perm_model=GroupObjectPermission,
|
|
||||||
identity_model=Group,
|
|
||||||
identity_field="group",
|
|
||||||
ids=entry["groups"],
|
|
||||||
codename=codename,
|
|
||||||
permission_objs=permission_objs,
|
|
||||||
ctype=ctype,
|
|
||||||
object_pks=object_pks,
|
|
||||||
merge=merge,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def permitted_object_ids(
|
def permitted_object_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
model: type[Model],
|
model: type[Model],
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ from documents.search._backend import TantivyRelevanceList
|
|||||||
from documents.search._backend import WriteBatch
|
from documents.search._backend import WriteBatch
|
||||||
from documents.search._backend import get_backend
|
from documents.search._backend import get_backend
|
||||||
from documents.search._backend import reset_backend
|
from documents.search._backend import reset_backend
|
||||||
|
from documents.search._errors import InvalidDateQuery
|
||||||
|
from documents.search._errors import InvalidNumberQuery
|
||||||
|
from documents.search._errors import MultipleSearchQueryErrors
|
||||||
|
from documents.search._errors import QueryTooLongError
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
|
from documents.search._errors import search_query_error_messages
|
||||||
from documents.search._schema import needs_rebuild
|
from documents.search._schema import needs_rebuild
|
||||||
from documents.search._schema import wipe_index
|
from documents.search._schema import wipe_index
|
||||||
from documents.search._translate import InvalidDateQuery
|
|
||||||
from documents.search._translate import SearchQueryError
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"InvalidDateQuery",
|
"InvalidDateQuery",
|
||||||
|
"InvalidNumberQuery",
|
||||||
|
"MultipleSearchQueryErrors",
|
||||||
|
"QueryTooLongError",
|
||||||
"SearchHit",
|
"SearchHit",
|
||||||
"SearchIndexLockError",
|
"SearchIndexLockError",
|
||||||
"SearchMode",
|
"SearchMode",
|
||||||
@@ -23,5 +30,6 @@ __all__ = [
|
|||||||
"get_backend",
|
"get_backend",
|
||||||
"needs_rebuild",
|
"needs_rebuild",
|
||||||
"reset_backend",
|
"reset_backend",
|
||||||
|
"search_query_error_messages",
|
||||||
"wipe_index",
|
"wipe_index",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import tantivy
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import get_current_timezone
|
from django.utils.timezone import get_current_timezone
|
||||||
|
|
||||||
from documents.search._query import build_permission_filter
|
|
||||||
from documents.search._query import extract_cjk_text
|
from documents.search._query import extract_cjk_text
|
||||||
from documents.search._query import parse_simple_text_highlight_query
|
from documents.search._query import parse_simple_text_highlight_query
|
||||||
from documents.search._query import parse_simple_text_query
|
from documents.search._query import parse_simple_text_query
|
||||||
@@ -40,6 +39,7 @@ from documents.utils import QuerySetStream
|
|||||||
from documents.utils import identity
|
from documents.utils import identity
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -285,6 +285,47 @@ class WriteBatch:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_permission_filter(
|
||||||
|
schema: tantivy.Schema,
|
||||||
|
user: AbstractUser,
|
||||||
|
viewer_group_ids: Iterable[int] = (),
|
||||||
|
) -> tantivy.Query:
|
||||||
|
"""
|
||||||
|
Build a query filter for user document permissions.
|
||||||
|
|
||||||
|
Creates a query that matches only documents visible to the specified user
|
||||||
|
according to paperless-ngx permission rules:
|
||||||
|
- Public documents (no owner) are visible to all users
|
||||||
|
- Private documents are visible to their owner
|
||||||
|
- Documents explicitly shared with the user are visible
|
||||||
|
- Documents shared with one of the user's current groups are visible
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Tantivy schema for field validation
|
||||||
|
user: User to check permissions for
|
||||||
|
viewer_group_ids: Current group memberships for the user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tantivy query that filters results to visible documents
|
||||||
|
"""
|
||||||
|
owner_any = tantivy.Query.exists_query("owner_id")
|
||||||
|
no_owner = tantivy.Query.boolean_query(
|
||||||
|
[
|
||||||
|
(tantivy.Occur.Must, tantivy.Query.all_query()),
|
||||||
|
(tantivy.Occur.MustNot, owner_any),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
owned = tantivy.Query.term_query(schema, "owner_id", user.pk)
|
||||||
|
shared = tantivy.Query.term_query(schema, "viewer_id", user.pk)
|
||||||
|
group_shared = [
|
||||||
|
tantivy.Query.term_query(schema, "viewer_group_id", group_id)
|
||||||
|
for group_id in viewer_group_ids
|
||||||
|
]
|
||||||
|
return tantivy.Query.disjunction_max_query(
|
||||||
|
[no_owner, owned, shared, *group_shared],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TantivyBackend:
|
class TantivyBackend:
|
||||||
"""
|
"""
|
||||||
Tantivy search backend with explicit lifecycle management.
|
Tantivy search backend with explicit lifecycle management.
|
||||||
@@ -458,7 +499,6 @@ class TantivyBackend:
|
|||||||
doc.add_text("correspondent_sort", document.correspondent.name)
|
doc.add_text("correspondent_sort", document.correspondent.name)
|
||||||
if cjk_corr := extract_cjk_text(document.correspondent.name):
|
if cjk_corr := extract_cjk_text(document.correspondent.name):
|
||||||
doc.add_text("bigram_correspondent", cjk_corr)
|
doc.add_text("bigram_correspondent", cjk_corr)
|
||||||
doc.add_unsigned("correspondent_id", document.correspondent_id)
|
|
||||||
|
|
||||||
# Document type
|
# Document type
|
||||||
if document.document_type:
|
if document.document_type:
|
||||||
@@ -466,12 +506,10 @@ class TantivyBackend:
|
|||||||
doc.add_text("type_sort", document.document_type.name)
|
doc.add_text("type_sort", document.document_type.name)
|
||||||
if cjk_type := extract_cjk_text(document.document_type.name):
|
if cjk_type := extract_cjk_text(document.document_type.name):
|
||||||
doc.add_text("bigram_document_type", cjk_type)
|
doc.add_text("bigram_document_type", cjk_type)
|
||||||
doc.add_unsigned("document_type_id", document.document_type_id)
|
|
||||||
|
|
||||||
# Storage path
|
# Storage path
|
||||||
if document.storage_path:
|
if document.storage_path:
|
||||||
doc.add_text("storage_path", document.storage_path.name)
|
doc.add_text("storage_path", document.storage_path.name)
|
||||||
doc.add_unsigned("storage_path_id", document.storage_path_id)
|
|
||||||
|
|
||||||
# Tags — collect names for autocomplete in the same pass
|
# Tags — collect names for autocomplete in the same pass
|
||||||
tag_names: list[str] = []
|
tag_names: list[str] = []
|
||||||
@@ -479,12 +517,13 @@ class TantivyBackend:
|
|||||||
doc.add_text("tag", tag.name)
|
doc.add_text("tag", tag.name)
|
||||||
if cjk_tag := extract_cjk_text(tag.name):
|
if cjk_tag := extract_cjk_text(tag.name):
|
||||||
doc.add_text("bigram_tag", cjk_tag)
|
doc.add_text("bigram_tag", cjk_tag)
|
||||||
doc.add_unsigned("tag_id", tag.pk)
|
|
||||||
tag_names.append(tag.name)
|
tag_names.append(tag.name)
|
||||||
|
|
||||||
# Notes — JSON for structured queries (notes.user:alice, notes.note:text).
|
# Notes — JSON for structured queries (notes.user:alice, notes.note:text).
|
||||||
# notes_text is a plain-text companion for snippet/highlight generation;
|
# notes_text is a plain-text companion for snippet/highlight generation;
|
||||||
# tantivy's SnippetGenerator does not support JSON fields.
|
# tantivy's SnippetGenerator does not support JSON fields. It is not in
|
||||||
|
# _DEFAULT_SEARCH_FIELDS, so an unqualified query never searches it: a
|
||||||
|
# note matches through the JSON field or not at all.
|
||||||
num_notes = 0
|
num_notes = 0
|
||||||
note_texts: list[str] = []
|
note_texts: list[str] = []
|
||||||
for note in document.notes.all():
|
for note in document.notes.all():
|
||||||
@@ -500,8 +539,9 @@ class TantivyBackend:
|
|||||||
if note_texts:
|
if note_texts:
|
||||||
doc.add_text("notes_text", " ".join(note_texts))
|
doc.add_text("notes_text", " ".join(note_texts))
|
||||||
|
|
||||||
# Custom fields — JSON for structured queries (custom_fields.name:x, custom_fields.value:y),
|
# Custom fields: JSON for structured queries (custom_fields.name:x,
|
||||||
# companion text field for default full-text search.
|
# custom_fields.value:y). There is no companion text field here, unlike
|
||||||
|
# notes: custom field values are reachable only through the JSON field.
|
||||||
for cfi in document.custom_fields.all():
|
for cfi in document.custom_fields.all():
|
||||||
search_value = cfi.value_for_search
|
search_value = cfi.value_for_search
|
||||||
# Skip fields where there is no value yet
|
# Skip fields where there is no value yet
|
||||||
@@ -668,7 +708,17 @@ class TantivyBackend:
|
|||||||
user_query = self._parse_query(query, search_mode)
|
user_query = self._parse_query(query, search_mode)
|
||||||
highlight_query = user_query
|
highlight_query = user_query
|
||||||
if search_mode is SearchMode.TEXT:
|
if search_mode is SearchMode.TEXT:
|
||||||
highlight_query = parse_simple_text_highlight_query(self._index, query)
|
try:
|
||||||
|
highlight_query = parse_simple_text_highlight_query(
|
||||||
|
self._index,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping simple text highlight query: token string is not "
|
||||||
|
"valid tantivy query syntax: %r",
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
|
||||||
# For notes_text snippet generation, we need a query that targets the
|
# For notes_text snippet generation, we need a query that targets the
|
||||||
# notes_text field directly. user_query may contain JSON-field terms
|
# notes_text field directly. user_query may contain JSON-field terms
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import date
|
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from datetime import tzinfo
|
|
||||||
|
|
||||||
_DATE_ONLY_FIELDS = frozenset({"created"})
|
|
||||||
|
|
||||||
_TODAY: Final[str] = "today"
|
|
||||||
_YESTERDAY: Final[str] = "yesterday"
|
|
||||||
_PREVIOUS_WEEK: Final[str] = "previous week"
|
|
||||||
_THIS_MONTH: Final[str] = "this month"
|
|
||||||
_PREVIOUS_MONTH: Final[str] = "previous month"
|
|
||||||
_THIS_YEAR: Final[str] = "this year"
|
|
||||||
_PREVIOUS_YEAR: Final[str] = "previous year"
|
|
||||||
_PREVIOUS_QUARTER: Final[str] = "previous quarter"
|
|
||||||
|
|
||||||
_DATE_KEYWORDS = frozenset(
|
|
||||||
{
|
|
||||||
_TODAY,
|
|
||||||
_YESTERDAY,
|
|
||||||
_PREVIOUS_WEEK,
|
|
||||||
_THIS_MONTH,
|
|
||||||
_PREVIOUS_MONTH,
|
|
||||||
_THIS_YEAR,
|
|
||||||
_PREVIOUS_YEAR,
|
|
||||||
_PREVIOUS_QUARTER,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt(dt: datetime) -> str:
|
|
||||||
"""Format a datetime as an ISO 8601 UTC string for use in Tantivy range queries."""
|
|
||||||
return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def _iso_range(lo: datetime, hi: datetime) -> str:
|
|
||||||
"""
|
|
||||||
Format a half-open ``[lo TO hi)`` range in ISO 8601 for Tantivy query syntax.
|
|
||||||
|
|
||||||
``hi`` is always the exclusive ceiling of a computed period (the start of
|
|
||||||
the *next* day/week/month/quarter/year), so the closing bracket must be
|
|
||||||
the Tantivy exclusive-range brace ``}`` rather than ``]`` — otherwise the
|
|
||||||
first instant of the following period (e.g. the 1st of next month) is
|
|
||||||
incorrectly included in the match.
|
|
||||||
"""
|
|
||||||
return f"[{_fmt(lo)} TO {_fmt(hi)}}}"
|
|
||||||
|
|
||||||
|
|
||||||
def _quarter_start(d: date) -> date:
|
|
||||||
"""Return the first day of the calendar quarter containing ``d``."""
|
|
||||||
return date(d.year, ((d.month - 1) // 3) * 3 + 1, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _midnight(d: date, tz: tzinfo) -> datetime:
|
|
||||||
"""Convert a calendar date at local-timezone midnight to a UTC datetime."""
|
|
||||||
return datetime(d.year, d.month, d.day, tzinfo=tz).astimezone(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _keyword_bounds(keyword: str, tz: tzinfo) -> tuple[date, date]:
|
|
||||||
"""
|
|
||||||
Map a relative date keyword to ``(start, exclusive_end)`` calendar dates.
|
|
||||||
|
|
||||||
``tz`` only determines what "today" is; the caller decides how the returned
|
|
||||||
dates become UTC datetime boundaries (date-only vs. local-midnight offset).
|
|
||||||
"""
|
|
||||||
today = datetime.now(tz).date()
|
|
||||||
if keyword == _TODAY:
|
|
||||||
return today, today + timedelta(days=1)
|
|
||||||
if keyword == _YESTERDAY:
|
|
||||||
return today - timedelta(days=1), today
|
|
||||||
if keyword == _PREVIOUS_WEEK:
|
|
||||||
this_monday = today - timedelta(days=today.weekday())
|
|
||||||
return this_monday - timedelta(weeks=1), this_monday
|
|
||||||
if keyword == _THIS_MONTH:
|
|
||||||
first = today.replace(day=1)
|
|
||||||
return first, first + relativedelta(months=1)
|
|
||||||
if keyword == _PREVIOUS_MONTH:
|
|
||||||
this_first = today.replace(day=1)
|
|
||||||
return this_first - relativedelta(months=1), this_first
|
|
||||||
if keyword == _THIS_YEAR:
|
|
||||||
return date(today.year, 1, 1), date(today.year + 1, 1, 1)
|
|
||||||
if keyword == _PREVIOUS_YEAR:
|
|
||||||
return date(today.year - 1, 1, 1), date(today.year, 1, 1)
|
|
||||||
if keyword == _PREVIOUS_QUARTER:
|
|
||||||
this_quarter = _quarter_start(today)
|
|
||||||
return this_quarter - relativedelta(months=3), this_quarter
|
|
||||||
raise ValueError(f"Unknown keyword: {keyword}")
|
|
||||||
|
|
||||||
|
|
||||||
def _date_only_range(keyword: str, tz: tzinfo) -> str:
|
|
||||||
"""
|
|
||||||
For `created` (DateField): use the local calendar date, converted to
|
|
||||||
midnight UTC boundaries. No offset arithmetic — date only.
|
|
||||||
"""
|
|
||||||
start, end = _keyword_bounds(keyword, tz)
|
|
||||||
lo = datetime(start.year, start.month, start.day, tzinfo=UTC)
|
|
||||||
hi = datetime(end.year, end.month, end.day, tzinfo=UTC)
|
|
||||||
return _iso_range(lo, hi)
|
|
||||||
|
|
||||||
|
|
||||||
def _datetime_range(keyword: str, tz: tzinfo) -> str:
|
|
||||||
"""
|
|
||||||
For `added` / `modified` (DateTimeField, stored as UTC): convert local day
|
|
||||||
boundaries to UTC — full offset arithmetic required.
|
|
||||||
"""
|
|
||||||
start, end = _keyword_bounds(keyword, tz)
|
|
||||||
return _iso_range(_midnight(start, tz), _midnight(end, tz))
|
|
||||||
|
|
||||||
|
|
||||||
def _precision_bounds(digits: str) -> tuple[date, date] | None:
|
|
||||||
"""
|
|
||||||
Map a 4/6/8-digit date token to (start, exclusive_end) calendar dates.
|
|
||||||
|
|
||||||
YYYY -> whole year, YYYYMM -> whole month, YYYYMMDD -> single day.
|
|
||||||
Returns None for any unparsable or out-of-range value (e.g. month 23),
|
|
||||||
so callers can emit a no-match clause instead of erroring (Whoosh parity).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if len(digits) == 4:
|
|
||||||
year = int(digits)
|
|
||||||
return date(year, 1, 1), date(year + 1, 1, 1)
|
|
||||||
if len(digits) == 6:
|
|
||||||
year, month = int(digits[:4]), int(digits[4:6])
|
|
||||||
start = date(year, month, 1)
|
|
||||||
end = date(year + 1, 1, 1) if month == 12 else date(year, month + 1, 1)
|
|
||||||
return start, end
|
|
||||||
if len(digits) == 8:
|
|
||||||
start = date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
|
|
||||||
return start, start + timedelta(days=1)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _utc_bounds_for_field(
|
|
||||||
field: str,
|
|
||||||
start: date,
|
|
||||||
end: date,
|
|
||||||
tz: tzinfo,
|
|
||||||
) -> tuple[datetime, datetime]:
|
|
||||||
"""
|
|
||||||
Convert calendar-date bounds to UTC datetimes per the field's storage type.
|
|
||||||
|
|
||||||
For DateField (``created``) the bounds are UTC midnight (no offset). For
|
|
||||||
DateTimeField (``added``/``modified``) the bounds are local-tz midnight
|
|
||||||
converted to UTC, matching how each field is indexed.
|
|
||||||
"""
|
|
||||||
if field in _DATE_ONLY_FIELDS:
|
|
||||||
return (
|
|
||||||
datetime(start.year, start.month, start.day, tzinfo=UTC),
|
|
||||||
datetime(end.year, end.month, end.day, tzinfo=UTC),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
datetime(start.year, start.month, start.day, tzinfo=tz).astimezone(UTC),
|
|
||||||
datetime(end.year, end.month, end.day, tzinfo=tz).astimezone(UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _field_range_from_dates(field: str, start: date, end: date, tz: tzinfo) -> str:
|
|
||||||
"""Build a Tantivy ``field:[lo TO hi]`` ISO range from calendar-date bounds."""
|
|
||||||
lo, hi = _utc_bounds_for_field(field, start, end, tz)
|
|
||||||
return f"{field}:{_iso_range(lo, hi)}"
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
class SearchQueryError(ValueError):
|
||||||
|
"""
|
||||||
|
Base for user-fixable search query errors.
|
||||||
|
|
||||||
|
Carries a message safe to surface to the user (no internal details). The
|
||||||
|
view layer catches this and returns an HTTP 400, so any future subclass
|
||||||
|
gets the same treatment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidDateQuery(SearchQueryError):
|
||||||
|
"""Raised when a date field value or range bound cannot be parsed."""
|
||||||
|
|
||||||
|
def __init__(self, field: str | None, value: str | None) -> None:
|
||||||
|
self.field = field
|
||||||
|
self.value = value
|
||||||
|
super().__init__(f"Invalid date value {value!r} for field {field!r}.")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidNumberQuery(SearchQueryError):
|
||||||
|
"""Raised when a numeric field value or range bound cannot be parsed."""
|
||||||
|
|
||||||
|
def __init__(self, field: str | None, value: str | None) -> None:
|
||||||
|
self.field = field
|
||||||
|
self.value = value
|
||||||
|
super().__init__(f"Invalid numeric value {value!r} for field {field!r}.")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryTooLongError(SearchQueryError):
|
||||||
|
"""Raised when a query string exceeds the maximum allowed length.
|
||||||
|
|
||||||
|
whoosh-compat's fieldname tagger is O(n^2) in plain word characters, so an
|
||||||
|
unbounded query is a CPU-exhaustion vector against a single request
|
||||||
|
handler. This is a hard boundary, not a validation nicety.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, length: int, limit: int) -> None:
|
||||||
|
self.length = length
|
||||||
|
self.limit = limit
|
||||||
|
super().__init__(
|
||||||
|
f"The search query is too long ({length} characters). "
|
||||||
|
f"The maximum allowed length is {limit} characters.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleSearchQueryErrors(SearchQueryError):
|
||||||
|
"""Aggregates every user-fixable error from one parse, not just the first."""
|
||||||
|
|
||||||
|
def __init__(self, errors: Sequence[SearchQueryError]) -> None:
|
||||||
|
self.errors = tuple(errors)
|
||||||
|
super().__init__("; ".join(str(e) for e in self.errors))
|
||||||
|
|
||||||
|
|
||||||
|
def search_query_error_messages(e: SearchQueryError) -> list[str]:
|
||||||
|
"""The user-facing message list for a SearchQueryError.
|
||||||
|
|
||||||
|
Every offending value's message, not just the first, so the user can
|
||||||
|
fix them all in one round-trip. Shared by every view that maps
|
||||||
|
SearchQueryError to an HTTP 400.
|
||||||
|
"""
|
||||||
|
if isinstance(e, MultipleSearchQueryErrors):
|
||||||
|
return [str(sub) for sub in e.errors]
|
||||||
|
return [str(e)]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
from whoosh_compat import FieldSpec
|
||||||
|
from whoosh_compat import SubpathSpec
|
||||||
|
|
||||||
|
# Internal-only schema fields with no query-syntax meaning of their own
|
||||||
|
# (sort shadow fields, bigram CJK fields, simple_title/simple_content,
|
||||||
|
# autocomplete_word, notes_text) are NOT represented here, they are
|
||||||
|
# declared in _schema.py's field_descriptors().
|
||||||
|
#
|
||||||
|
# analyzer/pattern_normalizer are deliberately left at FieldSpec's default
|
||||||
|
# (None): they're language-specific and only meaningful to whoosh-compat's
|
||||||
|
# parser, so _registry.py attaches them per-language via dataclasses.replace()
|
||||||
|
# rather than PUBLIC_FIELDS declaring them itself. _schema.py only reads
|
||||||
|
# name/kind/fast and never sees the analyzer at all.
|
||||||
|
PUBLIC_FIELDS: tuple[FieldSpec, ...] = (
|
||||||
|
FieldSpec("title", FieldKind.TEXT),
|
||||||
|
FieldSpec("content", FieldKind.TEXT),
|
||||||
|
FieldSpec("correspondent", FieldKind.TEXT),
|
||||||
|
FieldSpec("document_type", FieldKind.TEXT, aliases=("type",)),
|
||||||
|
FieldSpec("storage_path", FieldKind.TEXT, aliases=("path",)),
|
||||||
|
FieldSpec("original_filename", FieldKind.TEXT),
|
||||||
|
FieldSpec("tag", FieldKind.TEXT, comma_values=True),
|
||||||
|
FieldSpec("checksum", FieldKind.KEYWORD),
|
||||||
|
FieldSpec("asn", FieldKind.U64, fast=True),
|
||||||
|
FieldSpec("page_count", FieldKind.U64, fast=True),
|
||||||
|
FieldSpec("num_notes", FieldKind.U64, fast=True),
|
||||||
|
FieldSpec("created", FieldKind.DATE, date_only=True, fast=True),
|
||||||
|
FieldSpec("modified", FieldKind.DATETIME, fast=True),
|
||||||
|
FieldSpec("added", FieldKind.DATETIME, fast=True),
|
||||||
|
FieldSpec(
|
||||||
|
"notes",
|
||||||
|
FieldKind.JSON,
|
||||||
|
subpaths={"user": SubpathSpec(), "note": SubpathSpec(default=True)},
|
||||||
|
),
|
||||||
|
FieldSpec(
|
||||||
|
"custom_fields",
|
||||||
|
FieldKind.JSON,
|
||||||
|
subpaths={"name": SubpathSpec(), "value": SubpathSpec(default=True)},
|
||||||
|
),
|
||||||
|
)
|
||||||
+450
-145
@@ -6,22 +6,30 @@ from typing import Final
|
|||||||
|
|
||||||
import regex
|
import regex
|
||||||
import tantivy
|
import tantivy
|
||||||
|
import whoosh_compat as wc
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from whoosh_compat.emitters.tantivy_ import emit as tantivy_emit
|
||||||
|
from whoosh_compat.errors import Cause
|
||||||
|
from whoosh_compat.errors import Diagnostic
|
||||||
|
from whoosh_compat.errors import DiagnosticKind
|
||||||
|
from whoosh_compat.errors import QueryError
|
||||||
|
|
||||||
|
from documents.search._errors import InvalidDateQuery
|
||||||
|
from documents.search._errors import InvalidNumberQuery
|
||||||
|
from documents.search._errors import MultipleSearchQueryErrors
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
|
from documents.search._registry import get_field_registry
|
||||||
from documents.search._tokenizer import simple_search_tokens
|
from documents.search._tokenizer import simple_search_tokens
|
||||||
from documents.search._translate import SearchQueryError
|
|
||||||
from documents.search._translate import translate_query
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable
|
|
||||||
from datetime import tzinfo
|
from datetime import tzinfo
|
||||||
|
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.search")
|
logger = logging.getLogger("paperless.search")
|
||||||
|
|
||||||
# Maximum seconds any single regex substitution may run.
|
# Maximum seconds any single regex substitution over user-supplied query text
|
||||||
# Prevents ReDoS on adversarial user-supplied query strings.
|
# may run. The one remaining use is a character class, which cannot backtrack,
|
||||||
|
# so the bound is an upper limit on that substitution's cost, not the ReDoS
|
||||||
|
# guard it was originally written as.
|
||||||
_REGEX_TIMEOUT: Final[float] = 1.0
|
_REGEX_TIMEOUT: Final[float] = 1.0
|
||||||
|
|
||||||
# Matches CJK/Hangul characters so queries can be routed to bigram fields.
|
# Matches CJK/Hangul characters so queries can be routed to bigram fields.
|
||||||
@@ -29,6 +37,64 @@ _REGEX_TIMEOUT: Final[float] = 1.0
|
|||||||
_CJK_RE: Final = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]+")
|
_CJK_RE: Final = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_facing_emit_message(d: Diagnostic) -> str:
|
||||||
|
"""A user-safe message for an emit-time QueryError's Diagnostic.
|
||||||
|
|
||||||
|
Built from the Diagnostic's structured fields (kind, field), never from
|
||||||
|
d.message: whoosh-compat documents that as developer/log output with no
|
||||||
|
stability guarantee, and PATTERN_TOO_COMPLEX embeds the raw backend
|
||||||
|
error text in it.
|
||||||
|
"""
|
||||||
|
field = str(d.field) if d.field is not None else None
|
||||||
|
if d.kind is DiagnosticKind.EXISTS_REQUIRES_FAST:
|
||||||
|
return f"Existence searches (field:*) are not supported for field {field!r}."
|
||||||
|
if d.kind is DiagnosticKind.TEXT_RANGE:
|
||||||
|
return f"Range searches are not supported for field {field!r}."
|
||||||
|
if d.kind is DiagnosticKind.PATTERN_TOO_COMPLEX:
|
||||||
|
return f"The wildcard pattern for field {field!r} is too complex."
|
||||||
|
if d.kind is DiagnosticKind.SCHEMA_FIELD_MISSING:
|
||||||
|
return f"Field {field!r} is not available in the search index."
|
||||||
|
logger.warning("Unmapped emit diagnostic %s: %s", d.kind, d.message)
|
||||||
|
return "The search query could not be executed."
|
||||||
|
|
||||||
|
|
||||||
|
def _map_emit_error(e: QueryError) -> SearchQueryError:
|
||||||
|
"""Route an emit-time QueryError by its Diagnostic's Cause.
|
||||||
|
|
||||||
|
INVALID_INPUT/UNSUPPORTED are user-input errors, exactly like a parse
|
||||||
|
diagnostic, and map to a 400. INTERNAL means a defect in whoosh-compat
|
||||||
|
or in our own AST handling, never the user's query, so the QueryError is
|
||||||
|
re-raised rather than converted, reaching the generic 500 handler instead
|
||||||
|
of blaming the query. MISCONFIGURED is deliberately both: the registry and
|
||||||
|
the index schema disagree, which only an operator can fix, so it is logged
|
||||||
|
as an error, but a request is still waiting and the query cannot run
|
||||||
|
either way, so it also returns a 400.
|
||||||
|
|
||||||
|
EXISTS_REQUIRES_FAST is the one MISCONFIGURED kind that is not a
|
||||||
|
disagreement. whoosh-compat derives it from the registry's own FieldSpec
|
||||||
|
(kind plus fast) without ever consulting the index schema, so it fires
|
||||||
|
whenever a non-fast field of a kind that cannot answer "exists" is asked
|
||||||
|
to: for us that is only the JSON fields, which field_descriptors() builds
|
||||||
|
non-fast on purpose. "notes:*" and the five other spellings of it are
|
||||||
|
ordinary user error that no operator action can clear, so they get the
|
||||||
|
400 without the alert.
|
||||||
|
"""
|
||||||
|
d = e.diagnostic
|
||||||
|
if d.cause is Cause.INTERNAL:
|
||||||
|
raise e
|
||||||
|
if (
|
||||||
|
d.cause is Cause.MISCONFIGURED
|
||||||
|
and d.kind is not DiagnosticKind.EXISTS_REQUIRES_FAST
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
"Search index misconfiguration for field %s (%s): %s",
|
||||||
|
d.field,
|
||||||
|
d.kind.name,
|
||||||
|
d.message,
|
||||||
|
)
|
||||||
|
return SearchQueryError(_user_facing_emit_message(d))
|
||||||
|
|
||||||
|
|
||||||
def _has_cjk(text: str) -> bool:
|
def _has_cjk(text: str) -> bool:
|
||||||
"""Return True if text contains any CJK characters."""
|
"""Return True if text contains any CJK characters."""
|
||||||
return bool(_CJK_RE.search(text))
|
return bool(_CJK_RE.search(text))
|
||||||
@@ -37,14 +103,36 @@ def _has_cjk(text: str) -> bool:
|
|||||||
def extract_cjk_text(text: str) -> str:
|
def extract_cjk_text(text: str) -> str:
|
||||||
"""Join the CJK runs in ``text`` for indexing into bigram (char-ngram) fields.
|
"""Join the CJK runs in ``text`` for indexing into bigram (char-ngram) fields.
|
||||||
|
|
||||||
Mirrors the query side (``_build_cjk_query``): only CJK runs are ever searched
|
Mirrors the query side, which extracts the CJK runs of whatever it is
|
||||||
against the bigram fields, so only CJK runs are worth indexing there. Latin
|
about to search for (the raw string in simple modes, the parsed query's
|
||||||
text fed to a character-bigram field is never matched and only bloats the
|
free-text tokens in query mode): only CJK runs are ever searched against
|
||||||
|
the bigram fields, so only CJK runs are worth indexing there. Latin text
|
||||||
|
fed to a character-bigram field is never matched and only bloats the
|
||||||
index and slows indexing/merge. Returns "" when there is no CJK text.
|
index and slows indexing/merge. Returns "" when there is no CJK text.
|
||||||
"""
|
"""
|
||||||
return " ".join(_CJK_RE.findall(text))
|
return " ".join(_CJK_RE.findall(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cjk_text(
|
||||||
|
index: tantivy.Index,
|
||||||
|
cjk_text: str,
|
||||||
|
fields: list[str],
|
||||||
|
) -> tantivy.Query | None:
|
||||||
|
"""Parse a plain CJK run string against ``fields``, or None if it won't parse."""
|
||||||
|
try:
|
||||||
|
return index.parse_query(cjk_text, fields)
|
||||||
|
except Exception:
|
||||||
|
# Broad on purpose, unlike _try_parse_fuzzy_query's narrower
|
||||||
|
# ValueError: cjk_text isn't filtered to a guaranteed-safe token
|
||||||
|
# set the way the fuzzy blend's word string is, so the exact
|
||||||
|
# failure mode tantivy could raise here isn't pinned down.
|
||||||
|
logger.debug(
|
||||||
|
"Skipping CJK search clause: could not parse CJK text: %r",
|
||||||
|
cjk_text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_cjk_query(
|
def _build_cjk_query(
|
||||||
index: tantivy.Index,
|
index: tantivy.Index,
|
||||||
raw_query: str,
|
raw_query: str,
|
||||||
@@ -52,91 +140,259 @@ def _build_cjk_query(
|
|||||||
) -> tantivy.Query | None:
|
) -> tantivy.Query | None:
|
||||||
"""Build a bigram-field query from the CJK runs in ``raw_query``.
|
"""Build a bigram-field query from the CJK runs in ``raw_query``.
|
||||||
|
|
||||||
Only the CJK character runs are extracted and parsed; ASCII field prefixes,
|
For the simple (TEXT/TITLE) modes, whose input is plain text and carries
|
||||||
boolean operators and date keywords are discarded. This keeps the CJK clause
|
no query grammar to respect. Only the CJK character runs are extracted, so
|
||||||
plain-text and consistent across query/simple modes (no leaked ``field:``
|
a stray ``field:`` prefix or ``-``/``+`` in the input can neither leak
|
||||||
semantics, no parse failures from spaced ``-``/``+``), and avoids feeding
|
field semantics nor fail the parse, and no Latin token reaches the
|
||||||
Latin tokens into the character-bigram matcher (which would produce spurious
|
character-bigram matcher (where it would produce spurious matches against
|
||||||
matches against unrelated Latin text). Returns None when there is no CJK
|
unrelated Latin text). Returns None when there is no CJK text or the parse
|
||||||
text or the parse fails.
|
fails.
|
||||||
"""
|
"""
|
||||||
cjk_text = " ".join(_CJK_RE.findall(raw_query))
|
cjk_text = extract_cjk_text(raw_query)
|
||||||
if not cjk_text:
|
if not cjk_text:
|
||||||
return None
|
return None
|
||||||
|
return _parse_cjk_text(index, cjk_text, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ast_cjk_query(
|
||||||
|
index: tantivy.Index,
|
||||||
|
ast: wc.ast.Node,
|
||||||
|
registry: wc.FieldRegistry,
|
||||||
|
) -> tantivy.Query | None:
|
||||||
|
"""Build the bigram clause of a QUERY-mode search from the parsed AST.
|
||||||
|
|
||||||
|
Same discipline as the fuzzy clause (see _try_parse_fuzzy_query): the CJK
|
||||||
|
runs come from whoosh_compat's ``free_text_tokens`` over the parsed tree,
|
||||||
|
never from the raw query string, so a term the user negated or restricted
|
||||||
|
to a field outside the default search fields contributes nothing, instead
|
||||||
|
of resurfacing as a top-level clause matching every bigram field.
|
||||||
|
|
||||||
|
``free_text_tokens`` reports no field of its own, so the tokens are
|
||||||
|
collected one default field at a time: a bare term, which the parser has
|
||||||
|
already copied onto every default field, is therefore searched across
|
||||||
|
every bigram field, while ``title:東京`` reaches ``bigram_title`` alone.
|
||||||
|
Fields whose CJK text is identical (the bare-term case) share a single
|
||||||
|
parse over all of their bigram fields at once.
|
||||||
|
|
||||||
|
Raw (``analyzed=False``) tokens are used because the bigram fields have
|
||||||
|
their own character-ngram analyzer: the default fields' word analyzers
|
||||||
|
have no useful say over a CJK run, and running them first would only
|
||||||
|
risk dropping it (remove_long) before the run is ever extracted.
|
||||||
|
Returns None when the query has no CJK free text.
|
||||||
|
"""
|
||||||
|
fields_by_text: dict[str, list[str]] = {}
|
||||||
|
for field, bigram_field in _CJK_BIGRAM_FIELDS.items():
|
||||||
|
tokens = wc.free_text_tokens(
|
||||||
|
ast,
|
||||||
|
registry=registry,
|
||||||
|
fields=[field],
|
||||||
|
analyzed=False,
|
||||||
|
)
|
||||||
|
cjk_text = extract_cjk_text(" ".join(tokens))
|
||||||
|
if cjk_text:
|
||||||
|
fields_by_text.setdefault(cjk_text, []).append(bigram_field)
|
||||||
|
|
||||||
|
clauses: list[tuple[tantivy.Occur, tantivy.Query]] = [
|
||||||
|
(tantivy.Occur.Should, query)
|
||||||
|
for cjk_text, bigram_fields in fields_by_text.items()
|
||||||
|
if (query := _parse_cjk_text(index, cjk_text, bigram_fields)) is not None
|
||||||
|
]
|
||||||
|
return _any_of(clauses) if clauses else None
|
||||||
|
|
||||||
|
|
||||||
|
# A joined fuzzy word string must stay plain words: it goes back through
|
||||||
|
# tantivy's own query parser, and the raw query text the clause collects
|
||||||
|
# routinely carries characters that parser reads as grammar (a colon, a
|
||||||
|
# bracket, a quote, a leading -). Each token is cut into its word runs and
|
||||||
|
# only those are kept, so no field syntax, pattern, range or grouping can
|
||||||
|
# reach the parser. Cutting rather than dropping the whole token is what
|
||||||
|
# keeps ordinary hyphenated, dotted and quoted input ("COVID-19",
|
||||||
|
# "hello@example.com", "tax reports") contributing to the clause at all.
|
||||||
|
_WORD_RUN_RE = regex.compile(r"\w+")
|
||||||
|
|
||||||
|
# The one piece of tantivy grammar that survives the cut: its boolean
|
||||||
|
# keywords are themselves word runs. Only these exact spellings are
|
||||||
|
# grammar there ("And"/"and" are ordinary terms), so lowercasing exactly
|
||||||
|
# these turns them back into the ordinary terms the field analyzer used to
|
||||||
|
# make of them, before the clause switched to raw text. Left alone, a
|
||||||
|
# quoted phrase would silently restructure the clause ("tax AND reports"
|
||||||
|
# becoming a conjunction) or fail to parse and drop it entirely
|
||||||
|
# ("tax AND", or "IN" anywhere).
|
||||||
|
#
|
||||||
|
# Only these words are touched: tantivy lowercases query terms with the
|
||||||
|
# field's own analyzer, and doing it ourselves first is not always the
|
||||||
|
# same operation (Python folds a final sigma to a different letter than
|
||||||
|
# tantivy does, and turns Turkish 'İ' into a sequence tantivy then splits
|
||||||
|
# in two), which would search for terms the index does not contain.
|
||||||
|
_TANTIVY_KEYWORDS: Final[frozenset[str]] = frozenset({"AND", "OR", "NOT", "IN"})
|
||||||
|
|
||||||
|
|
||||||
|
def _try_parse_fuzzy_query(
|
||||||
|
index: tantivy.Index,
|
||||||
|
ast: wc.ast.Node,
|
||||||
|
registry: wc.FieldRegistry,
|
||||||
|
) -> tantivy.Query | None:
|
||||||
|
"""Build the fuzzy blend clause from the parsed query's free-text
|
||||||
|
words, or None if it has none.
|
||||||
|
|
||||||
|
The clause is built by handing tantivy's own query parser a plain
|
||||||
|
word string (there's no clean AST-level fuzzy equivalent to
|
||||||
|
whoosh-compat's parse tree, and fuzzy matching was always an
|
||||||
|
approximate, secondary, 0.1-boosted clause). The words come from
|
||||||
|
whoosh_compat's ``free_text_tokens`` over the already-parsed AST,
|
||||||
|
never from the raw query string: raw whoosh grammar (date keywords,
|
||||||
|
``[2005 to 2009]`` ranges, bracket-class wildcards) is not tantivy
|
||||||
|
syntax, and feeding it here used to knock the fuzzy clause out for
|
||||||
|
the whole query the moment any such construct appeared alongside a
|
||||||
|
typo'd word. The helper also keeps excluded terms out: a ``NOT``'d
|
||||||
|
word must not resurface through the fuzzy clause.
|
||||||
|
|
||||||
|
Chosen trade-off: a term explicitly fielded on one of the default
|
||||||
|
search fields (``correspondent:acme``) contributes its text to the
|
||||||
|
word string UNFIELDED, so the fuzzy clause searches it across all
|
||||||
|
default fields rather than just the one the user named. That is
|
||||||
|
recall-only widening on a secondary 0.1-boosted clause the score
|
||||||
|
threshold already disciplines, accepted in exchange for never feeding
|
||||||
|
field syntax to tantivy's parser. What the word string guarantees is
|
||||||
|
exactly that: no field prefix, pattern, range, grouping or quoting
|
||||||
|
survives, and the boolean keywords that do survive (they are word
|
||||||
|
runs) are lowercased into ordinary terms; see _TANTIVY_KEYWORDS.
|
||||||
|
|
||||||
|
The words are the query's RAW text, not the analyzer's output
|
||||||
|
(``analyzed=False``), because ``index.parse_query`` analyzes whatever
|
||||||
|
it is given and analysis is not idempotent: ``universities`` stems to
|
||||||
|
``univers``, and handing that back stems it again to ``univ``, a term
|
||||||
|
the index does not contain. ``prefix=True`` hid this as over-broad
|
||||||
|
matching (``univ`` also prefixes ``unicycle``) rather than as no
|
||||||
|
matches at all. Raw text is untokenized, which is why it is cut into
|
||||||
|
word runs above rather than taken whole.
|
||||||
|
|
||||||
|
The ValueError guard stays as insurance (the word string is plain
|
||||||
|
tokens, so tantivy accepting it is expected, not assumed): on a parse
|
||||||
|
failure the fuzzy clause is skipped and the exact/CJK clauses stand,
|
||||||
|
rather than the whole query failing.
|
||||||
|
"""
|
||||||
|
tokens = wc.free_text_tokens(
|
||||||
|
ast,
|
||||||
|
registry=registry,
|
||||||
|
fields=_DEFAULT_SEARCH_FIELDS,
|
||||||
|
analyzed=False,
|
||||||
|
)
|
||||||
|
words = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
word.lower() if word in _TANTIVY_KEYWORDS else word
|
||||||
|
for token in tokens
|
||||||
|
for word in _WORD_RUN_RE.findall(token)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not words:
|
||||||
|
return None
|
||||||
|
fuzzy_text = " ".join(words)
|
||||||
try:
|
try:
|
||||||
return index.parse_query(cjk_text, fields)
|
return index.parse_query(
|
||||||
except Exception:
|
fuzzy_text,
|
||||||
|
_DEFAULT_SEARCH_FIELDS,
|
||||||
|
field_boosts=_FIELD_BOOSTS,
|
||||||
|
fuzzy_fields={f: (True, 1, True) for f in _DEFAULT_SEARCH_FIELDS},
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping fuzzy search clause: token string is not valid "
|
||||||
|
"tantivy query syntax: %r",
|
||||||
|
fuzzy_text,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_permission_filter(
|
_DEFAULT_SEARCH_FIELDS: Final[list[str]] = [
|
||||||
schema: tantivy.Schema,
|
|
||||||
user: AbstractBaseUser,
|
|
||||||
viewer_group_ids: Iterable[int] = (),
|
|
||||||
) -> tantivy.Query:
|
|
||||||
"""
|
|
||||||
Build a query filter for user document permissions.
|
|
||||||
|
|
||||||
Creates a query that matches only documents visible to the specified user
|
|
||||||
according to paperless-ngx permission rules:
|
|
||||||
- Public documents (no owner) are visible to all users
|
|
||||||
- Private documents are visible to their owner
|
|
||||||
- Documents explicitly shared with the user are visible
|
|
||||||
- Documents shared with one of the user's current groups are visible
|
|
||||||
|
|
||||||
Args:
|
|
||||||
schema: Tantivy schema for field validation
|
|
||||||
user: User to check permissions for
|
|
||||||
viewer_group_ids: Current group memberships for the user
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tantivy query that filters results to visible documents
|
|
||||||
"""
|
|
||||||
owner_any = tantivy.Query.exists_query("owner_id")
|
|
||||||
no_owner = tantivy.Query.boolean_query(
|
|
||||||
[
|
|
||||||
(tantivy.Occur.Must, tantivy.Query.all_query()),
|
|
||||||
(tantivy.Occur.MustNot, owner_any),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
owned = tantivy.Query.term_query(schema, "owner_id", user.pk)
|
|
||||||
shared = tantivy.Query.term_query(schema, "viewer_id", user.pk)
|
|
||||||
group_shared = [
|
|
||||||
tantivy.Query.term_query(schema, "viewer_group_id", group_id)
|
|
||||||
for group_id in viewer_group_ids
|
|
||||||
]
|
|
||||||
return tantivy.Query.disjunction_max_query(
|
|
||||||
[no_owner, owned, shared, *group_shared],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_SEARCH_FIELDS = [
|
|
||||||
"title",
|
"title",
|
||||||
"content",
|
"content",
|
||||||
"correspondent",
|
"correspondent",
|
||||||
"document_type",
|
"document_type",
|
||||||
"tag",
|
"tag",
|
||||||
]
|
]
|
||||||
SIMPLE_SEARCH_FIELDS = ["simple_title", "simple_content"]
|
_SIMPLE_SEARCH_FIELDS: Final[list[str]] = ["simple_title", "simple_content"]
|
||||||
TITLE_SEARCH_FIELDS = ["simple_title"]
|
_TITLE_SEARCH_FIELDS: Final[list[str]] = ["simple_title"]
|
||||||
_CJK_ALL_FIELDS: Final[list[str]] = [
|
# The bigram (character-ngram) companion of each default search field.
|
||||||
"bigram_content",
|
_CJK_BIGRAM_FIELDS: Final[dict[str, str]] = {
|
||||||
"bigram_title",
|
field: f"bigram_{field}" for field in _DEFAULT_SEARCH_FIELDS
|
||||||
"bigram_correspondent",
|
}
|
||||||
"bigram_document_type",
|
|
||||||
"bigram_tag",
|
|
||||||
]
|
|
||||||
_CJK_CONTENT_FIELDS: Final[list[str]] = ["bigram_content"]
|
_CJK_CONTENT_FIELDS: Final[list[str]] = ["bigram_content"]
|
||||||
_CJK_TITLE_FIELDS: Final[list[str]] = ["bigram_title"]
|
_CJK_TITLE_FIELDS: Final[list[str]] = ["bigram_title"]
|
||||||
_FIELD_BOOSTS = {"title": 2.0}
|
_FIELD_BOOSTS = {"title": 2.0}
|
||||||
_SIMPLE_FIELD_BOOSTS = {"simple_title": 2.0}
|
_SIMPLE_FIELD_BOOSTS = {"simple_title": 2.0}
|
||||||
|
|
||||||
|
|
||||||
def _simple_query_tokens(raw_query: str) -> list[str]:
|
class _ConjunctiveNegations(wc.ast.Visitor[tuple["wc.ast.Node", ...]]):
|
||||||
# Tokenize and fold via the same analyzer used to index simple_title /
|
"""Collect the subtrees an AST excludes from every document it matches.
|
||||||
# simple_content, so query terms fold identically to the indexed terms
|
|
||||||
# (single source of truth for ASCII folding).
|
A negation reached through ``And``/``AndNot``/``Require`` (and through
|
||||||
return simple_search_tokens(raw_query)
|
the required half of an ``AndMaybe``) constrains the whole query, so it
|
||||||
|
can be re-stated above the blend. ``Or`` is deliberately not descended
|
||||||
|
into: in ``invoice OR NOT secret`` the negation is one branch's own
|
||||||
|
condition, and hoisting it would throw away documents the other branch
|
||||||
|
matches. Nor is a collected subtree descended into, since a negation
|
||||||
|
inside a negation is not an exclusion.
|
||||||
|
|
||||||
|
Node types with no negation to contribute (every leaf, ``Or``) fall
|
||||||
|
through to ``generic_visit``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def generic_visit(self, node: wc.ast.Node) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def visit_not(self, node: wc.ast.Not) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return (node.child,)
|
||||||
|
|
||||||
|
def visit_andnot(self, node: wc.ast.AndNot) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return (*self.visit(node.positive), node.negative)
|
||||||
|
|
||||||
|
def visit_and(self, node: wc.ast.And) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return tuple(
|
||||||
|
negation for child in node.children for negation in self.visit(child)
|
||||||
|
)
|
||||||
|
|
||||||
|
def visit_boosted(self, node: wc.ast.Boosted) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return self.visit(node.child)
|
||||||
|
|
||||||
|
def visit_andmaybe(self, node: wc.ast.AndMaybe) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return self.visit(node.required)
|
||||||
|
|
||||||
|
def visit_require(self, node: wc.ast.Require) -> tuple[wc.ast.Node, ...]:
|
||||||
|
return (*self.visit(node.scored), *self.visit(node.filter_only))
|
||||||
|
|
||||||
|
|
||||||
|
def _negation_clauses(
|
||||||
|
index: tantivy.Index,
|
||||||
|
ast: wc.ast.Node,
|
||||||
|
registry: wc.FieldRegistry,
|
||||||
|
) -> list[tuple[tantivy.Occur, tantivy.Query]]:
|
||||||
|
"""MustNot clauses for everything ``ast`` excludes conjunctively.
|
||||||
|
|
||||||
|
Each excluded subtree is emitted as its own positive query and attached
|
||||||
|
with ``MustNot``, rather than emitting a negative query and hoping
|
||||||
|
tantivy accepts a bare one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
tantivy.Occur.MustNot,
|
||||||
|
tantivy_emit(negation, index=index, registry=registry),
|
||||||
|
)
|
||||||
|
for negation in _ConjunctiveNegations().visit(ast)
|
||||||
|
]
|
||||||
|
except QueryError as e:
|
||||||
|
raise _map_emit_error(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _any_of(clauses: list[tuple[tantivy.Occur, tantivy.Query]]) -> tantivy.Query:
|
||||||
|
"""Collapse a clause list: none -> empty, one -> itself (no wasted
|
||||||
|
single-clause boolean_query wrapping), many -> boolean_query(clauses)."""
|
||||||
|
if not clauses:
|
||||||
|
return tantivy.Query.empty_query()
|
||||||
|
if len(clauses) == 1:
|
||||||
|
return clauses[0][1]
|
||||||
|
return tantivy.Query.boolean_query(clauses)
|
||||||
|
|
||||||
|
|
||||||
def _build_simple_token_query(
|
def _build_simple_token_query(
|
||||||
@@ -168,9 +424,7 @@ def _build_simple_token_query(
|
|||||||
query = tantivy.Query.boost_query(query, boost)
|
query = tantivy.Query.boost_query(query, boost)
|
||||||
field_queries.append((tantivy.Occur.Should, query))
|
field_queries.append((tantivy.Occur.Should, query))
|
||||||
|
|
||||||
if len(field_queries) == 1:
|
return _any_of(field_queries)
|
||||||
return field_queries[0][1]
|
|
||||||
return tantivy.Query.boolean_query(field_queries)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_user_query(
|
def parse_user_query(
|
||||||
@@ -179,52 +433,53 @@ def parse_user_query(
|
|||||||
tz: tzinfo,
|
tz: tzinfo,
|
||||||
) -> tantivy.Query:
|
) -> tantivy.Query:
|
||||||
"""
|
"""
|
||||||
Parse user query through the complete preprocessing pipeline.
|
Parse user query through whoosh-compat, then blend in fuzzy/CJK clauses.
|
||||||
|
|
||||||
Transforms the raw user query through multiple stages:
|
1. wc.parse() against the shared FieldRegistry (whoosh grammar -> AST).
|
||||||
1. Date keyword rewriting (today → ISO 8601 ranges)
|
Bare notes:/custom_fields: prefixes resolve to their default subpath
|
||||||
2. Query normalization (comma expansion, whitespace cleanup)
|
(notes.note:/custom_fields.value:) directly in the registry, via
|
||||||
3. Tantivy parsing with field boosts
|
each JSON field's SubpathSpec(default=True).
|
||||||
4. Optional fuzzy query blending (if ADVANCED_FUZZY_SEARCH_THRESHOLD set)
|
2. Any diagnostics (bad dates/numbers) map to SearchQueryError subclasses
|
||||||
|
and raise, the view returns HTTP 400 with every offending field
|
||||||
Args:
|
listed, not just the first.
|
||||||
index: Tantivy index with registered tokenizers
|
3. emit() turns the AST into a tantivy.Query directly (no string
|
||||||
raw_query: Original user query string
|
round-trip). A QueryError is routed by its Diagnostic's Cause
|
||||||
tz: Timezone for date boundary calculations
|
(_map_emit_error): a construct that parses but can't execute against
|
||||||
|
tantivy (e.g. a text-field range) is a 400, a registry/schema
|
||||||
Returns:
|
mismatch is logged and a 400, and an INTERNAL defect is re-raised.
|
||||||
Parsed Tantivy query ready for execution
|
4. Optional fuzzy blend (ADVANCED_FUZZY_SEARCH_THRESHOLD) builds a
|
||||||
|
plain word string from the parsed AST's free-text tokens
|
||||||
Note:
|
(whoosh_compat.free_text_tokens) and feeds THAT to
|
||||||
When ADVANCED_FUZZY_SEARCH_THRESHOLD is configured, adds a low-priority
|
index.parse_query, never raw_query, whose whoosh grammar (date
|
||||||
fuzzy query as a Should clause (0.1 boost) to catch approximate matches
|
keywords, bracket-class wildcards, etc.) tantivy's parser rejects,
|
||||||
while keeping exact matches ranked higher. The threshold value is applied
|
which used to silently knock the fuzzy clause out of any mixed
|
||||||
as a post-search score filter, not during query construction.
|
query (see _try_parse_fuzzy_query).
|
||||||
|
5. Optional CJK bigram clause, built from the same parsed AST for the
|
||||||
|
same reason (see _build_ast_cjk_query): a CJK term the query negated
|
||||||
|
or fielded must not resurface through it.
|
||||||
|
6. When any optional clause was added, the query's conjunctive
|
||||||
|
exclusions are restated as MustNot above the blend
|
||||||
|
(_negation_clauses): a clause built from positive terms cannot
|
||||||
|
express them, and as a bare Should it would undo them.
|
||||||
"""
|
"""
|
||||||
|
registry = get_field_registry(settings.SEARCH_LANGUAGE)
|
||||||
|
result = wc.parse(
|
||||||
|
raw_query,
|
||||||
|
registry=registry,
|
||||||
|
default_fields=_DEFAULT_SEARCH_FIELDS,
|
||||||
|
field_boosts=_FIELD_BOOSTS,
|
||||||
|
tz=tz,
|
||||||
|
)
|
||||||
|
if result.diagnostics:
|
||||||
|
raise _diagnostics_to_error(result.diagnostics)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
query_str = translate_query(raw_query, tz)
|
exact = tantivy_emit(result.ast, index=index, registry=registry)
|
||||||
except SearchQueryError:
|
except QueryError as e:
|
||||||
# Intentional, user-fixable error (e.g. an unparsable date). Propagate so
|
raise _map_emit_error(e) from e
|
||||||
# the view can return a 400 with a helpful message rather than falling
|
|
||||||
# back to the raw (still-invalid) query.
|
|
||||||
raise
|
|
||||||
except Exception: # pragma: no cover - defensive
|
|
||||||
logger.warning("Query translation failed; using raw query", exc_info=True)
|
|
||||||
query_str = raw_query
|
|
||||||
|
|
||||||
exact = index.parse_query(
|
|
||||||
query_str,
|
|
||||||
DEFAULT_SEARCH_FIELDS,
|
|
||||||
field_boosts=_FIELD_BOOSTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
# The standard analyzer keeps a whitespace-free CJK run as a single token,
|
|
||||||
# so substring queries can't match content/title (and long runs are dropped
|
|
||||||
# by remove_long). Route CJK queries to the bigram fields, whose ngram
|
|
||||||
# tokenizer indexes overlapping 2-grams for substring matching.
|
|
||||||
cjk_query = (
|
cjk_query = (
|
||||||
_build_cjk_query(index, raw_query, _CJK_ALL_FIELDS)
|
_build_ast_cjk_query(index, result.ast, registry)
|
||||||
if _has_cjk(raw_query)
|
if _has_cjk(raw_query)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -235,22 +490,73 @@ def parse_user_query(
|
|||||||
|
|
||||||
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
|
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
|
||||||
if threshold is not None:
|
if threshold is not None:
|
||||||
fuzzy = index.parse_query(
|
fuzzy = _try_parse_fuzzy_query(index, result.ast, registry)
|
||||||
query_str,
|
if fuzzy is not None:
|
||||||
DEFAULT_SEARCH_FIELDS,
|
clauses.append(
|
||||||
field_boosts=_FIELD_BOOSTS,
|
(tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)),
|
||||||
# (prefix=True, distance=1, transposition_cost_one=True) — edit-distance fuzziness
|
)
|
||||||
fuzzy_fields={f: (True, 1, True) for f in DEFAULT_SEARCH_FIELDS},
|
|
||||||
)
|
|
||||||
# 0.1 boost keeps fuzzy hits ranked below exact matches (intentional)
|
|
||||||
clauses.append((tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)))
|
|
||||||
|
|
||||||
if cjk_query is not None:
|
if cjk_query is not None:
|
||||||
clauses.append((tantivy.Occur.Should, cjk_query))
|
clauses.append((tantivy.Occur.Should, cjk_query))
|
||||||
|
|
||||||
if len(clauses) == 1:
|
if len(clauses) == 1:
|
||||||
return exact
|
return exact
|
||||||
return tantivy.Query.boolean_query(clauses)
|
# The fuzzy and CJK clauses are built from positive terms only, so as
|
||||||
|
# plain Shoulds beside the exact clause they re-admit exactly the
|
||||||
|
# documents the query excluded. Restate the exclusions once, above the
|
||||||
|
# whole blend. Redundant against the exact clause, which already
|
||||||
|
# carries them, but idempotently so.
|
||||||
|
negations = _negation_clauses(index, result.ast, registry)
|
||||||
|
if not negations:
|
||||||
|
return _any_of(clauses)
|
||||||
|
return tantivy.Query.boolean_query(
|
||||||
|
[(tantivy.Occur.Must, _any_of(clauses)), *negations],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The three whoosh-compat kinds for a wildcard on a field that cannot
|
||||||
|
# carry one. d.field_kind supplies the discriminator, so naming the field's
|
||||||
|
# type needs no second trip through the registry.
|
||||||
|
_PATTERN_ON_KINDS: Final = frozenset(
|
||||||
|
{
|
||||||
|
DiagnosticKind.PATTERN_ON_NUMERIC,
|
||||||
|
DiagnosticKind.PATTERN_ON_BOOLEAN_EXISTS,
|
||||||
|
DiagnosticKind.PATTERN_ON_SUBPATH,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostics_to_error(diagnostics: tuple[Diagnostic, ...]) -> SearchQueryError:
|
||||||
|
errors = [_single_diagnostic_to_error(d) for d in diagnostics]
|
||||||
|
return errors[0] if len(errors) == 1 else MultipleSearchQueryErrors(errors)
|
||||||
|
|
||||||
|
|
||||||
|
def _single_diagnostic_to_error(d: Diagnostic) -> SearchQueryError:
|
||||||
|
# d.field is a FieldRef, not a str: str(d.field) gives the canonical
|
||||||
|
# dotted name (an aliased query, e.g. type:, reports document_type).
|
||||||
|
field_name = str(d.field) if d.field is not None else None
|
||||||
|
if d.kind is DiagnosticKind.BAD_DATE:
|
||||||
|
return InvalidDateQuery(field_name, d.raw_value)
|
||||||
|
if d.kind is DiagnosticKind.BAD_NUMBER:
|
||||||
|
return InvalidNumberQuery(field_name, d.raw_value)
|
||||||
|
if d.kind is DiagnosticKind.TOO_DEEP:
|
||||||
|
return SearchQueryError("The search query is nested too deeply.")
|
||||||
|
if d.kind in _PATTERN_ON_KINDS:
|
||||||
|
kind_label = f" ({d.field_kind.name.lower()})" if d.field_kind else ""
|
||||||
|
return SearchQueryError(
|
||||||
|
f"Wildcard patterns are not supported for field "
|
||||||
|
f"{field_name!r}{kind_label}.",
|
||||||
|
)
|
||||||
|
if d.kind is DiagnosticKind.SINGLE_CHAR_BRACKET_RANGE:
|
||||||
|
field_label = f" for field {field_name!r}" if field_name else ""
|
||||||
|
return SearchQueryError(
|
||||||
|
f"{d.raw_value!r} looks like a bracket range{field_label}, but "
|
||||||
|
"'[' is not a wildcard character on its own. Combine it with a "
|
||||||
|
"wildcard, e.g. a trailing '*', or double-quote the value to "
|
||||||
|
"search it as literal text.",
|
||||||
|
)
|
||||||
|
logger.warning("Unmapped parse diagnostic %s: %s", d.kind, d.message)
|
||||||
|
return SearchQueryError("The search query could not be executed.")
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_query(
|
def parse_simple_query(
|
||||||
@@ -268,7 +574,7 @@ def parse_simple_query(
|
|||||||
CJK substrings the simple analyzer can't (long whitespace-free runs are
|
CJK substrings the simple analyzer can't (long whitespace-free runs are
|
||||||
dropped by remove_long).
|
dropped by remove_long).
|
||||||
"""
|
"""
|
||||||
tokens = _simple_query_tokens(raw_query)
|
tokens = simple_search_tokens(raw_query)
|
||||||
|
|
||||||
clauses: list[tuple[tantivy.Occur, tantivy.Query]] = []
|
clauses: list[tuple[tantivy.Occur, tantivy.Query]] = []
|
||||||
if tokens:
|
if tokens:
|
||||||
@@ -291,23 +597,14 @@ def parse_simple_query(
|
|||||||
)
|
)
|
||||||
for token in tokens
|
for token in tokens
|
||||||
]
|
]
|
||||||
simple_query = (
|
clauses.append((tantivy.Occur.Should, _any_of(token_queries)))
|
||||||
token_queries[0][1]
|
|
||||||
if len(token_queries) == 1
|
|
||||||
else tantivy.Query.boolean_query(token_queries)
|
|
||||||
)
|
|
||||||
clauses.append((tantivy.Occur.Should, simple_query))
|
|
||||||
|
|
||||||
if cjk_fields and _has_cjk(raw_query):
|
if cjk_fields and _has_cjk(raw_query):
|
||||||
cjk_q = _build_cjk_query(index, raw_query, cjk_fields)
|
cjk_q = _build_cjk_query(index, raw_query, cjk_fields)
|
||||||
if cjk_q is not None:
|
if cjk_q is not None:
|
||||||
clauses.append((tantivy.Occur.Should, cjk_q))
|
clauses.append((tantivy.Occur.Should, cjk_q))
|
||||||
|
|
||||||
if not clauses:
|
return _any_of(clauses)
|
||||||
return tantivy.Query.empty_query()
|
|
||||||
if len(clauses) == 1:
|
|
||||||
return clauses[0][1]
|
|
||||||
return tantivy.Query.boolean_query(clauses)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_text_highlight_query(
|
def parse_simple_text_highlight_query(
|
||||||
@@ -322,13 +619,21 @@ def parse_simple_text_highlight_query(
|
|||||||
|
|
||||||
# Strip Tantivy operator chars before tokenizing: this is a plain-text
|
# Strip Tantivy operator chars before tokenizing: this is a plain-text
|
||||||
# highlight query, not a structured boolean query, so +/- are separators.
|
# highlight query, not a structured boolean query, so +/- are separators.
|
||||||
tokens = _simple_query_tokens(
|
tokens = simple_search_tokens(
|
||||||
regex.sub(r"[-+]", " ", raw_query, timeout=_REGEX_TIMEOUT),
|
regex.sub(r"[-+]", " ", raw_query, timeout=_REGEX_TIMEOUT),
|
||||||
)
|
)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return tantivy.Query.empty_query()
|
return tantivy.Query.empty_query()
|
||||||
|
|
||||||
return index.parse_query(" ".join(tokens), ["content"])
|
# Quote each token as its own phrase, escaping backslashes and embedded
|
||||||
|
# quotes. simple search tokens can carry arbitrary Tantivy syntax
|
||||||
|
# characters (`"`, `:`, `(`, `[`, `/`, ...) that the query-string parser
|
||||||
|
# would otherwise interpret as query grammar rather than literal text.
|
||||||
|
quoted_tokens = [
|
||||||
|
'"' + token.replace("\\", "\\\\").replace('"', '\\"') + '"' for token in tokens
|
||||||
|
]
|
||||||
|
|
||||||
|
return index.parse_query(" ".join(quoted_tokens), ["content"])
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_text_query(
|
def parse_simple_text_query(
|
||||||
@@ -342,7 +647,7 @@ def parse_simple_text_query(
|
|||||||
return parse_simple_query(
|
return parse_simple_query(
|
||||||
index,
|
index,
|
||||||
raw_query,
|
raw_query,
|
||||||
SIMPLE_SEARCH_FIELDS,
|
_SIMPLE_SEARCH_FIELDS,
|
||||||
cjk_fields=_CJK_CONTENT_FIELDS,
|
cjk_fields=_CJK_CONTENT_FIELDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -358,6 +663,6 @@ def parse_simple_title_query(
|
|||||||
return parse_simple_query(
|
return parse_simple_query(
|
||||||
index,
|
index,
|
||||||
raw_query,
|
raw_query,
|
||||||
TITLE_SEARCH_FIELDS,
|
_TITLE_SEARCH_FIELDS,
|
||||||
cjk_fields=_CJK_TITLE_FIELDS,
|
cjk_fields=_CJK_TITLE_FIELDS,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
from whoosh_compat import FieldRegistry
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
from documents.search._tokenizer import ascii_fold
|
||||||
|
from documents.search._tokenizer import paperless_text_analyzer
|
||||||
|
from documents.search._tokenizer import stem_pattern_text
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from whoosh_compat import PatternNormalizer
|
||||||
|
|
||||||
|
_registry_cache: dict[str | None, FieldRegistry] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_analyzer(text: str) -> list[str]:
|
||||||
|
"""Analyzer for KEYWORD fields indexed with the raw tokenizer (no splitting)."""
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
|
||||||
|
def _fold_normalizer(text: str) -> str:
|
||||||
|
"""Wildcard/regex literal-run normalizer for fields indexed without stemming."""
|
||||||
|
return ascii_fold(text.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pattern_normalizer(language: str | None) -> PatternNormalizer:
|
||||||
|
"""Build the wildcard/regex literal-run normalizer for a search language."""
|
||||||
|
|
||||||
|
def _pattern_normalizer(text: str) -> tuple[str, ...]:
|
||||||
|
"""Normalize a literal run into the forms a term may match.
|
||||||
|
|
||||||
|
TEXT index terms go through lowercase -> ascii_fold -> stem, so a
|
||||||
|
pattern that skips stemming can never match one: "invoice*" would look
|
||||||
|
for a term starting with "invoice" while the index holds "invoic". The
|
||||||
|
run is therefore offered stemmed as well. KEYWORD fields are indexed
|
||||||
|
raw and get _fold_normalizer instead, so their patterns stay literal.
|
||||||
|
|
||||||
|
Both forms are returned, as alternatives, because neither is a prefix
|
||||||
|
of the other in general: English stemming substitutes as well as
|
||||||
|
truncates ("copy" -> "copi"), so the stem alone loses the compounds
|
||||||
|
the typed run reaches ("copyright") while the typed run alone loses
|
||||||
|
the inflections the stem reaches ("copies"). whoosh-compat ORs the
|
||||||
|
alternatives per literal run and deduplicates them, so a run the
|
||||||
|
stemmer leaves alone costs exactly the one branch it did before.
|
||||||
|
|
||||||
|
Inside a bracket class the emitter calls this once per character and
|
||||||
|
uses the answer only if it is a single one-character form; two forms
|
||||||
|
there leave the character as typed. A stemmer does not change a lone
|
||||||
|
character, so the two forms deduplicate to one and the class body is
|
||||||
|
folded as before.
|
||||||
|
"""
|
||||||
|
folded = ascii_fold(text.lower())
|
||||||
|
stemmed = stem_pattern_text(folded, language)
|
||||||
|
return (folded, stemmed)
|
||||||
|
|
||||||
|
return _pattern_normalizer
|
||||||
|
|
||||||
|
|
||||||
|
def get_field_registry(language: str | None) -> FieldRegistry:
|
||||||
|
"""Build (or return the cached) FieldRegistry for the given search language.
|
||||||
|
|
||||||
|
Cached keyed by language, rebuilt on the same trigger register_tokenizers()
|
||||||
|
uses (settings.SEARCH_LANGUAGE change). A fresh call with a new language
|
||||||
|
builds and caches a new registry rather than mutating the old one.
|
||||||
|
"""
|
||||||
|
if language in _registry_cache:
|
||||||
|
return _registry_cache[language]
|
||||||
|
|
||||||
|
text_analyzer = paperless_text_analyzer(language).analyze
|
||||||
|
pattern_normalizer = _make_pattern_normalizer(language)
|
||||||
|
|
||||||
|
specs = [
|
||||||
|
dataclasses.replace(
|
||||||
|
field,
|
||||||
|
analyzer=_identity_analyzer
|
||||||
|
if field.kind is FieldKind.KEYWORD
|
||||||
|
else text_analyzer,
|
||||||
|
pattern_normalizer=_fold_normalizer
|
||||||
|
if field.kind is FieldKind.KEYWORD
|
||||||
|
else pattern_normalizer,
|
||||||
|
)
|
||||||
|
for field in PUBLIC_FIELDS
|
||||||
|
]
|
||||||
|
|
||||||
|
registry = FieldRegistry(specs)
|
||||||
|
_registry_cache[language] = registry
|
||||||
|
return registry
|
||||||
+222
-83
@@ -1,14 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
from typing import NamedTuple
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import tantivy
|
import tantivy
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,7 +21,185 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger("paperless.search")
|
logger = logging.getLogger("paperless.search")
|
||||||
|
|
||||||
# v1 - Initial tantivy schema format
|
# v1 - Initial tantivy schema format
|
||||||
SCHEMA_VERSION: Final[int] = 1
|
# v2 - build_schema() derived from PUBLIC_FIELDS, changing the field declaration
|
||||||
|
# order, and the write-only correspondent/document_type/storage_path/tag id
|
||||||
|
# columns dropped. tantivy compares schemas by ordered field list, so an
|
||||||
|
# index built by v1 rejects every write against the v2 schema.
|
||||||
|
SCHEMA_VERSION: Final[int] = 2
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDescriptor(NamedTuple):
|
||||||
|
"""One tantivy field, in declaration order.
|
||||||
|
|
||||||
|
The descriptor vocabulary is paperless', not tantivy-py's: it is both the
|
||||||
|
input to the SchemaBuilder and the input to schema_fingerprint(), so the
|
||||||
|
persisted fingerprint cannot move under a tantivy-py upgrade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
stored: bool
|
||||||
|
indexed: bool
|
||||||
|
fast: bool
|
||||||
|
tokenizer: str | None
|
||||||
|
|
||||||
|
|
||||||
|
# (schema kind, tokenizer) for the FieldKind -> FieldDescriptor mapping that
|
||||||
|
# doesn't need special-casing. JSON is handled separately below since it can
|
||||||
|
# emit a second, synthetic descriptor.
|
||||||
|
_KIND_TABLE: Final[dict[FieldKind, tuple[str, str | None]]] = {
|
||||||
|
FieldKind.TEXT: ("text", "paperless_text"),
|
||||||
|
FieldKind.KEYWORD: ("text", "raw"),
|
||||||
|
FieldKind.U64: ("u64", None),
|
||||||
|
FieldKind.DATE: ("date", None),
|
||||||
|
FieldKind.DATETIME: ("date", None),
|
||||||
|
}
|
||||||
|
# Kinds whose fast-field flag follows FieldSpec.fast rather than always False.
|
||||||
|
_FAST_FROM_FIELD: Final[frozenset[FieldKind]] = frozenset(
|
||||||
|
{FieldKind.U64, FieldKind.DATE, FieldKind.DATETIME},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_field_descriptors() -> list[FieldDescriptor]:
|
||||||
|
"""Descriptors for the query-visible fields declared in PUBLIC_FIELDS."""
|
||||||
|
descriptors: list[FieldDescriptor] = []
|
||||||
|
for field in PUBLIC_FIELDS:
|
||||||
|
if field.kind is FieldKind.JSON:
|
||||||
|
descriptors.append(
|
||||||
|
FieldDescriptor(
|
||||||
|
field.name,
|
||||||
|
"json",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if field.name == "notes":
|
||||||
|
# Plain-text companion for snippet generation: tantivy's
|
||||||
|
# SnippetGenerator does not support JSON fields. Schema-only,
|
||||||
|
# no query-syntax meaning, not in PUBLIC_FIELDS.
|
||||||
|
descriptors.append(
|
||||||
|
FieldDescriptor(
|
||||||
|
"notes_text",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
schema_kind, tokenizer = _KIND_TABLE[field.kind]
|
||||||
|
descriptors.append(
|
||||||
|
FieldDescriptor(
|
||||||
|
field.name,
|
||||||
|
schema_kind,
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=field.fast if field.kind in _FAST_FROM_FIELD else False,
|
||||||
|
tokenizer=tokenizer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return descriptors
|
||||||
|
|
||||||
|
|
||||||
|
def field_descriptors() -> list[FieldDescriptor]:
|
||||||
|
"""Every field of the document index, in the order tantivy declares them.
|
||||||
|
|
||||||
|
tantivy compares schemas by *ordered* field list, so the order here is
|
||||||
|
part of the on-disk contract: schema_fingerprint() hashes it and
|
||||||
|
needs_rebuild() acts on the result.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
FieldDescriptor(
|
||||||
|
"id",
|
||||||
|
"u64",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
*_public_field_descriptors(),
|
||||||
|
# Shadow sort fields - fast, not stored
|
||||||
|
*(
|
||||||
|
FieldDescriptor(
|
||||||
|
name,
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer="simple_analyzer",
|
||||||
|
)
|
||||||
|
for name in ("title_sort", "correspondent_sort", "type_sort")
|
||||||
|
),
|
||||||
|
# CJK support - not stored, indexed only
|
||||||
|
*(
|
||||||
|
FieldDescriptor(
|
||||||
|
name,
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
)
|
||||||
|
for name in (
|
||||||
|
"bigram_content",
|
||||||
|
"bigram_title",
|
||||||
|
"bigram_correspondent",
|
||||||
|
"bigram_document_type",
|
||||||
|
"bigram_tag",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
# Simple substring search support for title/content - not stored,
|
||||||
|
# indexed only
|
||||||
|
*(
|
||||||
|
FieldDescriptor(
|
||||||
|
name,
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="simple_search_analyzer",
|
||||||
|
)
|
||||||
|
for name in ("simple_title", "simple_content")
|
||||||
|
),
|
||||||
|
# Autocomplete prefix scan via terms_with_prefix, which walks the
|
||||||
|
# field's term dictionary - so the field must be indexed (term dict),
|
||||||
|
# not stored. The stored value is never read back, so storing it only
|
||||||
|
# wastes space.
|
||||||
|
FieldDescriptor(
|
||||||
|
"autocomplete_word",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="raw",
|
||||||
|
),
|
||||||
|
# Permission filter columns, read by build_permission_filter.
|
||||||
|
*(
|
||||||
|
FieldDescriptor(
|
||||||
|
name,
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
)
|
||||||
|
for name in ("owner_id", "viewer_id", "viewer_group_id")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def schema_fingerprint() -> str:
|
||||||
|
"""Hash of the field descriptors, stamped into .index_settings.json.
|
||||||
|
|
||||||
|
Changes whenever a field is added, removed, retyped, re-optioned or
|
||||||
|
reordered, so an index built from a different schema shape is detected
|
||||||
|
even when SCHEMA_VERSION was not bumped.
|
||||||
|
"""
|
||||||
|
payload = json.dumps([list(descriptor) for descriptor in field_descriptors()])
|
||||||
|
return hashlib.blake2b(payload.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def build_schema() -> tantivy.Schema:
|
def build_schema() -> tantivy.Schema:
|
||||||
@@ -32,85 +215,37 @@ def build_schema() -> tantivy.Schema:
|
|||||||
"""
|
"""
|
||||||
sb = tantivy.SchemaBuilder()
|
sb = tantivy.SchemaBuilder()
|
||||||
|
|
||||||
sb.add_unsigned_field("id", stored=True, indexed=True, fast=True)
|
for descriptor in field_descriptors():
|
||||||
sb.add_text_field("checksum", stored=True, tokenizer_name="raw")
|
if descriptor.kind == "text":
|
||||||
|
sb.add_text_field(
|
||||||
for field in (
|
descriptor.name,
|
||||||
"title",
|
stored=descriptor.stored,
|
||||||
"correspondent",
|
fast=descriptor.fast,
|
||||||
"document_type",
|
tokenizer_name=cast("str", descriptor.tokenizer),
|
||||||
"storage_path",
|
)
|
||||||
"original_filename",
|
elif descriptor.kind == "json":
|
||||||
"content",
|
sb.add_json_field(
|
||||||
):
|
descriptor.name,
|
||||||
sb.add_text_field(field, stored=True, tokenizer_name="paperless_text")
|
stored=descriptor.stored,
|
||||||
|
fast=descriptor.fast,
|
||||||
# Shadow sort fields - fast, not stored/indexed
|
tokenizer_name=cast("str", descriptor.tokenizer),
|
||||||
for field in ("title_sort", "correspondent_sort", "type_sort"):
|
)
|
||||||
sb.add_text_field(
|
elif descriptor.kind == "u64":
|
||||||
field,
|
sb.add_unsigned_field(
|
||||||
stored=False,
|
descriptor.name,
|
||||||
tokenizer_name="simple_analyzer",
|
stored=descriptor.stored,
|
||||||
fast=True,
|
indexed=descriptor.indexed,
|
||||||
)
|
fast=descriptor.fast,
|
||||||
|
)
|
||||||
# CJK support - not stored, indexed only
|
elif descriptor.kind == "date":
|
||||||
sb.add_text_field("bigram_content", stored=False, tokenizer_name="bigram_analyzer")
|
sb.add_date_field(
|
||||||
sb.add_text_field("bigram_title", stored=False, tokenizer_name="bigram_analyzer")
|
descriptor.name,
|
||||||
sb.add_text_field(
|
stored=descriptor.stored,
|
||||||
"bigram_correspondent",
|
indexed=descriptor.indexed,
|
||||||
stored=False,
|
fast=descriptor.fast,
|
||||||
tokenizer_name="bigram_analyzer",
|
)
|
||||||
)
|
else:
|
||||||
sb.add_text_field(
|
raise ValueError(f"Unknown schema field kind: {descriptor.kind}")
|
||||||
"bigram_document_type",
|
|
||||||
stored=False,
|
|
||||||
tokenizer_name="bigram_analyzer",
|
|
||||||
)
|
|
||||||
sb.add_text_field("bigram_tag", stored=False, tokenizer_name="bigram_analyzer")
|
|
||||||
|
|
||||||
# Simple substring search support for title/content - not stored, indexed only
|
|
||||||
sb.add_text_field(
|
|
||||||
"simple_title",
|
|
||||||
stored=False,
|
|
||||||
tokenizer_name="simple_search_analyzer",
|
|
||||||
)
|
|
||||||
sb.add_text_field(
|
|
||||||
"simple_content",
|
|
||||||
stored=False,
|
|
||||||
tokenizer_name="simple_search_analyzer",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Autocomplete prefix scan via terms_with_prefix, which walks the field's
|
|
||||||
# term dictionary - so the field must be indexed (term dict), not stored.
|
|
||||||
# The stored value is never read back, so storing it only wastes space.
|
|
||||||
sb.add_text_field("autocomplete_word", stored=False, tokenizer_name="raw")
|
|
||||||
|
|
||||||
sb.add_text_field("tag", stored=True, tokenizer_name="paperless_text")
|
|
||||||
|
|
||||||
# JSON fields — structured queries: notes.user:alice, custom_fields.name:invoice
|
|
||||||
sb.add_json_field("notes", stored=True, tokenizer_name="paperless_text")
|
|
||||||
# Plain-text companion for notes — tantivy's SnippetGenerator does not support
|
|
||||||
# JSON fields, so highlights require a text field with the same content.
|
|
||||||
sb.add_text_field("notes_text", stored=True, tokenizer_name="paperless_text")
|
|
||||||
sb.add_json_field("custom_fields", stored=True, tokenizer_name="paperless_text")
|
|
||||||
|
|
||||||
for field in (
|
|
||||||
"correspondent_id",
|
|
||||||
"document_type_id",
|
|
||||||
"storage_path_id",
|
|
||||||
"tag_id",
|
|
||||||
"owner_id",
|
|
||||||
"viewer_id",
|
|
||||||
"viewer_group_id",
|
|
||||||
):
|
|
||||||
sb.add_unsigned_field(field, stored=False, indexed=True, fast=True)
|
|
||||||
|
|
||||||
for field in ("created", "modified", "added"):
|
|
||||||
sb.add_date_field(field, stored=True, indexed=True, fast=True)
|
|
||||||
|
|
||||||
for field in ("asn", "page_count", "num_notes"):
|
|
||||||
sb.add_unsigned_field(field, stored=True, indexed=True, fast=True)
|
|
||||||
|
|
||||||
return sb.build()
|
return sb.build()
|
||||||
|
|
||||||
@@ -119,9 +254,9 @@ def needs_rebuild(index_dir: Path) -> bool:
|
|||||||
"""
|
"""
|
||||||
Check if the search index needs rebuilding.
|
Check if the search index needs rebuilding.
|
||||||
|
|
||||||
Reads .index_settings.json to compare the stored schema version and
|
Reads .index_settings.json to compare the stored schema version, search
|
||||||
search language against the current configuration. Returns True if the
|
language and schema fingerprint against the current configuration. Returns
|
||||||
file is missing, unparsable, or either value mismatches.
|
True if the file is missing, unparsable, or any value mismatches.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index_dir: Path to the search index directory
|
index_dir: Path to the search index directory
|
||||||
@@ -140,6 +275,9 @@ def needs_rebuild(index_dir: Path) -> bool:
|
|||||||
if "language" not in data or data["language"] != settings.SEARCH_LANGUAGE:
|
if "language" not in data or data["language"] != settings.SEARCH_LANGUAGE:
|
||||||
logger.info("Search index language changed - rebuilding.")
|
logger.info("Search index language changed - rebuilding.")
|
||||||
return True
|
return True
|
||||||
|
if data.get("schema_fingerprint") != schema_fingerprint():
|
||||||
|
logger.info("Search index schema fingerprint mismatch - rebuilding.")
|
||||||
|
return True
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -170,6 +308,7 @@ def _write_sentinels(index_dir: Path) -> None:
|
|||||||
{
|
{
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
"language": settings.SEARCH_LANGUAGE,
|
"language": settings.SEARCH_LANGUAGE,
|
||||||
|
"schema_fingerprint": schema_fingerprint(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from functools import cache
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
import tantivy
|
import tantivy
|
||||||
@@ -71,7 +72,7 @@ def register_tokenizers(index: tantivy.Index, language: str | None) -> None:
|
|||||||
use fast=True and Tantivy requires fast-field tokenizers to exist
|
use fast=True and Tantivy requires fast-field tokenizers to exist
|
||||||
even for documents that omit those fields.
|
even for documents that omit those fields.
|
||||||
"""
|
"""
|
||||||
index.register_tokenizer("paperless_text", _paperless_text(language))
|
index.register_tokenizer("paperless_text", paperless_text_analyzer(language))
|
||||||
index.register_tokenizer("simple_analyzer", _simple_analyzer())
|
index.register_tokenizer("simple_analyzer", _simple_analyzer())
|
||||||
index.register_tokenizer("bigram_analyzer", _bigram_analyzer())
|
index.register_tokenizer("bigram_analyzer", _bigram_analyzer())
|
||||||
index.register_tokenizer("simple_search_analyzer", _simple_search_analyzer())
|
index.register_tokenizer("simple_search_analyzer", _simple_search_analyzer())
|
||||||
@@ -79,7 +80,7 @@ def register_tokenizers(index: tantivy.Index, language: str | None) -> None:
|
|||||||
index.register_fast_field_tokenizer("simple_analyzer", _simple_analyzer())
|
index.register_fast_field_tokenizer("simple_analyzer", _simple_analyzer())
|
||||||
|
|
||||||
|
|
||||||
def _paperless_text(language: str | None) -> tantivy.TextAnalyzer:
|
def paperless_text_analyzer(language: str | None) -> tantivy.TextAnalyzer:
|
||||||
"""Main full-text tokenizer for content, title, etc: simple -> remove_long(129) -> lowercase -> ascii_fold [-> stemmer]"""
|
"""Main full-text tokenizer for content, title, etc: simple -> remove_long(129) -> lowercase -> ascii_fold [-> stemmer]"""
|
||||||
builder = (
|
builder = (
|
||||||
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.simple())
|
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.simple())
|
||||||
@@ -100,6 +101,54 @@ def _paperless_text(language: str | None) -> tantivy.TextAnalyzer:
|
|||||||
return builder.build()
|
return builder.build()
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _pattern_stemmer(language: str | None) -> tantivy.TextAnalyzer | None:
|
||||||
|
"""The stemming tail of paperless_text_analyzer, over a whole literal run.
|
||||||
|
|
||||||
|
Same language gate and same Snowball stemmer paperless_text_analyzer
|
||||||
|
applies at index time, so query patterns follow SEARCH_LANGUAGE. Returns
|
||||||
|
None when that gate disables stemming; paperless_text_analyzer already
|
||||||
|
warns about an unsupported language, so this stays quiet.
|
||||||
|
|
||||||
|
The raw tokenizer keeps the run whole (a wildcard literal is a fragment,
|
||||||
|
not necessarily a word), and remove_long is kept so an over-long run is
|
||||||
|
treated the same way the index treats it.
|
||||||
|
"""
|
||||||
|
if not language:
|
||||||
|
return None
|
||||||
|
tantivy_lang = _LANGUAGE_MAP.get(language.lower())
|
||||||
|
if tantivy_lang is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
tantivy.TextAnalyzerBuilder(tantivy.Tokenizer.raw())
|
||||||
|
.filter(tantivy.Filter.remove_long(_TOKEN_REMOVE_LONG_LIMIT))
|
||||||
|
.filter(tantivy.Filter.stemmer(tantivy_lang))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stem_pattern_text(text: str, language: str | None) -> str:
|
||||||
|
"""Stem an already lowercased/ascii-folded run the way index terms are.
|
||||||
|
|
||||||
|
Returns text unchanged when stemming is disabled for language, and also
|
||||||
|
when the stem step does not yield exactly one token: remove_long drops a run
|
||||||
|
past the length limit, leaving no stem to substitute. Falling back to the
|
||||||
|
text as typed is the safe direction for a pattern prefix, since it can only
|
||||||
|
be as narrow as it was before stemming was considered.
|
||||||
|
|
||||||
|
The raw tokenizer emits one token whatever the input and the stemmer is
|
||||||
|
1-to-1, so only the zero-token case can fire today; the guard covers both
|
||||||
|
counts so a tokenizer change cannot turn this into an IndexError.
|
||||||
|
"""
|
||||||
|
analyzer = _pattern_stemmer(language)
|
||||||
|
if analyzer is None:
|
||||||
|
return text
|
||||||
|
tokens = analyzer.analyze(text)
|
||||||
|
if len(tokens) != 1:
|
||||||
|
return text
|
||||||
|
return tokens[0]
|
||||||
|
|
||||||
|
|
||||||
def _simple_analyzer() -> tantivy.TextAnalyzer:
|
def _simple_analyzer() -> tantivy.TextAnalyzer:
|
||||||
"""Tokenizer for shadow sort fields (title_sort, correspondent_sort, type_sort): simple -> lowercase -> ascii_fold."""
|
"""Tokenizer for shadow sort fields (title_sort, correspondent_sort, type_sort): simple -> lowercase -> ascii_fold."""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,610 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import TypeAlias
|
|
||||||
|
|
||||||
import regex
|
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
|
|
||||||
from documents.search._dates import _DATE_KEYWORDS
|
|
||||||
from documents.search._dates import _DATE_ONLY_FIELDS
|
|
||||||
from documents.search._dates import _date_only_range
|
|
||||||
from documents.search._dates import _datetime_range
|
|
||||||
from documents.search._dates import _field_range_from_dates
|
|
||||||
from documents.search._dates import _fmt
|
|
||||||
from documents.search._dates import _precision_bounds
|
|
||||||
from documents.search._dates import _utc_bounds_for_field
|
|
||||||
|
|
||||||
# Compiled regex that matches any known multi-word (or single-word) date keyword
|
|
||||||
# at the start of a match position, longest alternatives first so "previous week"
|
|
||||||
# wins over a hypothetical shorter "previous".
|
|
||||||
_KEYWORD_VALUE_RE = regex.compile(
|
|
||||||
"|".join(sorted((regex.escape(k) for k in _DATE_KEYWORDS), key=len, reverse=True)),
|
|
||||||
regex.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from datetime import tzinfo
|
|
||||||
|
|
||||||
# TODO: this module translates date queries into Tantivy *string* syntax, which
|
|
||||||
# forces a workaround for something Tantivy's string parser cannot express on
|
|
||||||
# date fields: open-ended ranges use far-past/far-future string sentinels
|
|
||||||
# (OPEN_LO/OPEN_HI). These can be replaced with a real tantivy.Query object
|
|
||||||
# (Query.range_query(..., None) for open bounds) once tantivy-py accepts Python
|
|
||||||
# datetimes in range_query/term_query on Date fields. That support exists on
|
|
||||||
# tantivy-py master (PRs #655 + #666) but postdates the pinned 0.26.0 wheel, so
|
|
||||||
# it is blocked only on a published release > 0.26.0 and a dependency bump.
|
|
||||||
# (Unparsable dates now raise InvalidDateQuery -> HTTP 400 rather than using a
|
|
||||||
# no-match string sentinel.)
|
|
||||||
|
|
||||||
# Fields that store exact, non-analyzed comma-joined tokens in the index and so
|
|
||||||
# need explicit comma->AND expansion (Whoosh KEYWORD(commas=True) set).
|
|
||||||
MULTI_VALUE_FIELDS = frozenset({"tag", "tag_id", "viewer_id"})
|
|
||||||
|
|
||||||
# Date fields whose values/ranges get rewritten to RFC3339 Tantivy ranges.
|
|
||||||
DATE_FIELDS = frozenset({"created", "modified", "added"})
|
|
||||||
|
|
||||||
# Field aliases: Whoosh (v2) field names that were renamed in the Tantivy schema.
|
|
||||||
# Preserved here so v2 queries using the old names continue to work without 400
|
|
||||||
# errors instead of silently failing. Applied by _render to non-date field tokens.
|
|
||||||
FIELD_ALIASES: dict[str, str] = {
|
|
||||||
"type": "document_type",
|
|
||||||
"type_id": "document_type_id",
|
|
||||||
"path": "storage_path",
|
|
||||||
"path_id": "storage_path_id",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Known schema fields: a comma immediately followed by ``<known>:`` is a clause
|
|
||||||
# separator. Restricting to known fields prevents URL-like ``http:`` misfires.
|
|
||||||
KNOWN_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"title",
|
|
||||||
"content",
|
|
||||||
"correspondent",
|
|
||||||
"document_type",
|
|
||||||
"type", # v2 alias -> document_type
|
|
||||||
"storage_path",
|
|
||||||
"path", # v2 alias -> storage_path
|
|
||||||
"tag",
|
|
||||||
"tag_id",
|
|
||||||
"correspondent_id",
|
|
||||||
"document_type_id",
|
|
||||||
"type_id", # v2 alias -> document_type_id
|
|
||||||
"storage_path_id",
|
|
||||||
"path_id", # v2 alias -> storage_path_id
|
|
||||||
"owner_id",
|
|
||||||
"viewer_id",
|
|
||||||
"asn",
|
|
||||||
"page_count",
|
|
||||||
"num_notes",
|
|
||||||
"created",
|
|
||||||
"modified",
|
|
||||||
"added",
|
|
||||||
"original_filename",
|
|
||||||
"checksum",
|
|
||||||
"notes",
|
|
||||||
"custom_fields",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
_FIELD_RE = regex.compile(r"(?P<field>\w+):")
|
|
||||||
|
|
||||||
# Matches the TO separator inside a range bracket. Handles three forms:
|
|
||||||
# middle: "lo TO hi" (either lo or hi may be empty)
|
|
||||||
# trailing: "lo TO" (open upper bound)
|
|
||||||
# leading: "TO hi" (open lower bound)
|
|
||||||
# Bounds MAY contain internal spaces (e.g. "-7 days"), so we use .*? / .+?
|
|
||||||
# and split on the whitespace-delimited " TO " / " to " separator.
|
|
||||||
_RANGE_RE = regex.compile(
|
|
||||||
r"^\s*(?P<lo>.*?)\s+[Tt][Oo]\s+(?P<hi>.+?)\s*$"
|
|
||||||
r"|"
|
|
||||||
r"^\s*(?P<lo2>.+?)\s+[Tt][Oo]\s*$"
|
|
||||||
r"|"
|
|
||||||
r"^\s*[Tt][Oo]\s+(?P<hi2>.+?)\s*$",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FieldValue:
|
|
||||||
field: str
|
|
||||||
value: str
|
|
||||||
|
|
||||||
|
|
||||||
# Produced by the comma-resolution pass (not by scan()).
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FieldValueList:
|
|
||||||
field: str
|
|
||||||
values: tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FieldRange:
|
|
||||||
field: str
|
|
||||||
open: str
|
|
||||||
lo: str
|
|
||||||
hi: str
|
|
||||||
close: str
|
|
||||||
|
|
||||||
|
|
||||||
# Produced by the comma-resolution pass (not by scan()).
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Comma:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Passthrough:
|
|
||||||
raw: str
|
|
||||||
|
|
||||||
|
|
||||||
Token: TypeAlias = FieldValue | FieldValueList | FieldRange | Comma | Passthrough
|
|
||||||
|
|
||||||
_CLOSE: dict[str, str] = {"[": "]", "{": "}"}
|
|
||||||
|
|
||||||
|
|
||||||
def scan(query: str) -> list[Token]:
|
|
||||||
"""
|
|
||||||
Tokenize a raw query into date/comma-aware tokens, leaving everything else
|
|
||||||
as verbatim ``Passthrough`` runs. Non-recursive: finds the first matching
|
|
||||||
close bracket/quote. Nested brackets are not valid Tantivy range syntax and
|
|
||||||
pass through verbatim on mismatch.
|
|
||||||
"""
|
|
||||||
tokens: list[Token] = []
|
|
||||||
buf: list[str] = [] # accumulates passthrough chars
|
|
||||||
i, n = 0, len(query)
|
|
||||||
while i < n:
|
|
||||||
matched = _match_field_token(query, i)
|
|
||||||
if matched is None:
|
|
||||||
buf.append(query[i])
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
token, i = matched
|
|
||||||
if buf and buf[-1] == ",":
|
|
||||||
buf.pop()
|
|
||||||
_flush(buf, tokens)
|
|
||||||
tokens.append(Comma())
|
|
||||||
else:
|
|
||||||
_flush(buf, tokens)
|
|
||||||
tokens.append(token)
|
|
||||||
i = _maybe_comma(query, i, tokens)
|
|
||||||
_flush(buf, tokens)
|
|
||||||
return tokens
|
|
||||||
|
|
||||||
|
|
||||||
def _flush(buf: list[str], tokens: list[Token]) -> None:
|
|
||||||
"""Emit any accumulated passthrough characters as a single token."""
|
|
||||||
if buf:
|
|
||||||
tokens.append(Passthrough("".join(buf)))
|
|
||||||
buf.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def _at_word_boundary(query: str, i: int) -> bool:
|
|
||||||
"""A field token may begin only at the start or after a non-word character."""
|
|
||||||
return i == 0 or not (query[i - 1].isalnum() or query[i - 1] == "_")
|
|
||||||
|
|
||||||
|
|
||||||
def _match_field_token(query: str, i: int) -> tuple[Token, int] | None:
|
|
||||||
"""
|
|
||||||
If a known ``field:`` token starts at ``i``, consume it and return
|
|
||||||
``(token, end_index)``; otherwise return None so the caller treats the
|
|
||||||
character as passthrough. Handles both ``field:[range]`` and ``field:value``,
|
|
||||||
and returns None when the range/value cannot be consumed.
|
|
||||||
"""
|
|
||||||
m = _FIELD_RE.match(query, i)
|
|
||||||
if m is None or m.group("field") not in KNOWN_FIELDS:
|
|
||||||
return None
|
|
||||||
if not _at_word_boundary(query, i):
|
|
||||||
return None
|
|
||||||
field = m.group("field")
|
|
||||||
j = m.end()
|
|
||||||
if j < len(query) and query[j] in "[{":
|
|
||||||
return _consume_range(query, j, field)
|
|
||||||
consumed = _consume_field_value(query, field, j)
|
|
||||||
if consumed is None:
|
|
||||||
return None
|
|
||||||
value, end = consumed
|
|
||||||
return FieldValue(field, value), end
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_field_value(query: str, field: str, start: int) -> tuple[str, int] | None:
|
|
||||||
"""
|
|
||||||
Consume a field value starting at ``start``: a multi-word date keyword phrase
|
|
||||||
(date fields only), or a bare/quoted value, then absorb any comma-joined
|
|
||||||
continuation that is not a clause separator. ``resolve_commas`` later splits a
|
|
||||||
multi-value field's joined value into a ``FieldValueList``; for other fields
|
|
||||||
the comma stays literal.
|
|
||||||
"""
|
|
||||||
n = len(query)
|
|
||||||
consumed = None
|
|
||||||
if field in DATE_FIELDS:
|
|
||||||
km = _KEYWORD_VALUE_RE.match(query, start)
|
|
||||||
if km is not None and (km.end() >= n or query[km.end()] in " \t),"):
|
|
||||||
consumed = (km.group(0), km.end())
|
|
||||||
if consumed is None:
|
|
||||||
consumed = _consume_value(query, start)
|
|
||||||
if consumed is None:
|
|
||||||
return None
|
|
||||||
value, k = consumed
|
|
||||||
while k < n and query[k] == ",":
|
|
||||||
if _looks_like_known_field(query, k + 1):
|
|
||||||
break # clause separator: left for _maybe_comma to emit a Comma()
|
|
||||||
more = _consume_value(query, k + 1)
|
|
||||||
if more is None:
|
|
||||||
break
|
|
||||||
value = f"{value},{more[0]}"
|
|
||||||
k = more[1]
|
|
||||||
return value, k
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_range(
|
|
||||||
query: str,
|
|
||||||
start: int,
|
|
||||||
field: str,
|
|
||||||
) -> tuple[FieldRange, int] | None:
|
|
||||||
"""Consume ``[lo TO hi]`` / ``{lo TO hi}`` from ``start`` (the bracket)."""
|
|
||||||
open_br = query[start]
|
|
||||||
close_br = _CLOSE[open_br]
|
|
||||||
end = query.find(close_br, start + 1)
|
|
||||||
if end == -1:
|
|
||||||
return None
|
|
||||||
inner = query[start + 1 : end]
|
|
||||||
m = _RANGE_RE.match(inner)
|
|
||||||
if m is not None:
|
|
||||||
if m.group("lo") is not None or m.group("hi") is not None:
|
|
||||||
# Middle form: "lo TO hi" (either may be empty string)
|
|
||||||
lo = (m.group("lo") or "").strip()
|
|
||||||
hi = (m.group("hi") or "").strip()
|
|
||||||
elif m.group("lo2") is not None:
|
|
||||||
# Trailing form: "lo TO"
|
|
||||||
lo = m.group("lo2").strip()
|
|
||||||
hi = ""
|
|
||||||
else:
|
|
||||||
# Leading form: "TO hi"
|
|
||||||
lo = ""
|
|
||||||
hi = (m.group("hi2") or "").strip()
|
|
||||||
else:
|
|
||||||
lo, hi = inner.strip(), ""
|
|
||||||
return FieldRange(field, open_br, lo, hi, close_br), end + 1
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_value(query: str, start: int) -> tuple[str, int] | None:
|
|
||||||
"""Consume a bare or quoted field value from ``start``, stopping at comma."""
|
|
||||||
n = len(query)
|
|
||||||
if start >= n or query[start] in " \t":
|
|
||||||
return None
|
|
||||||
if query[start] in "\"'":
|
|
||||||
quote = query[start]
|
|
||||||
end = query.find(quote, start + 1)
|
|
||||||
if end == -1:
|
|
||||||
return None
|
|
||||||
return query[start : end + 1], end + 1
|
|
||||||
j = start
|
|
||||||
while j < n and query[j] not in " \t),":
|
|
||||||
j += 1
|
|
||||||
return query[start:j], j
|
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_known_field(query: str, pos: int) -> bool:
|
|
||||||
"""True if a known ``field:`` token starts at ``pos``."""
|
|
||||||
m = _FIELD_RE.match(query, pos)
|
|
||||||
return bool(m and m.group("field") in KNOWN_FIELDS)
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_comma(query: str, i: int, tokens: list) -> int:
|
|
||||||
"""If a clause-separator comma follows at ``i``, emit ``Comma()`` and advance."""
|
|
||||||
if i < len(query) and query[i] == "," and _looks_like_known_field(query, i + 1):
|
|
||||||
tokens.append(Comma())
|
|
||||||
return i + 1
|
|
||||||
return i
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_commas(tokens: list) -> list:
|
|
||||||
"""
|
|
||||||
Collapse value-list commas into ``FieldValueList`` and keep clause-separator
|
|
||||||
commas as ``Comma``. (Clause-sep commas are already emitted by ``scan`` via
|
|
||||||
the value-stop logic; this pass folds value-lists.)
|
|
||||||
"""
|
|
||||||
out: list = []
|
|
||||||
for tok in tokens:
|
|
||||||
if (
|
|
||||||
isinstance(tok, FieldValue)
|
|
||||||
and tok.field in MULTI_VALUE_FIELDS
|
|
||||||
and "," in tok.value
|
|
||||||
):
|
|
||||||
values = tuple(v for v in tok.value.split(",") if v)
|
|
||||||
out.append(FieldValueList(tok.field, values))
|
|
||||||
else:
|
|
||||||
out.append(tok)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class SearchQueryError(ValueError):
|
|
||||||
"""
|
|
||||||
Base for user-fixable search query errors.
|
|
||||||
|
|
||||||
Carries a message safe to surface to the user (no internal details). The view
|
|
||||||
layer catches this and returns an HTTP 400, so any future subclass (unknown
|
|
||||||
field, malformed range, wrapped parser errors) gets the same treatment.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidDateQuery(SearchQueryError):
|
|
||||||
"""Raised when a date field value or range bound cannot be parsed."""
|
|
||||||
|
|
||||||
def __init__(self, field: str, value: str) -> None:
|
|
||||||
self.field = field
|
|
||||||
self.value = value
|
|
||||||
super().__init__(f"Invalid date value {value!r} for field {field!r}.")
|
|
||||||
|
|
||||||
|
|
||||||
_DIGITS_RE = regex.compile(r"^\d{4}(?:\d{2}){0,2}$")
|
|
||||||
_ISO_RE = regex.compile(r"^\d{4}(?:-\d{2}(?:-\d{2})?)?$")
|
|
||||||
|
|
||||||
|
|
||||||
def translate_scalar(field: str, value: str, tz: tzinfo) -> str:
|
|
||||||
"""Translate a bare date-field value to a Tantivy range string."""
|
|
||||||
bare = value.strip("\"'").lower()
|
|
||||||
if bare in _DATE_KEYWORDS:
|
|
||||||
if field in _DATE_ONLY_FIELDS:
|
|
||||||
return f"{field}:{_date_only_range(bare, tz)}"
|
|
||||||
return f"{field}:{_datetime_range(bare, tz)}"
|
|
||||||
digits = value.replace("-", "")
|
|
||||||
if _DIGITS_RE.match(value) or _ISO_RE.match(value):
|
|
||||||
bounds = _precision_bounds(digits)
|
|
||||||
if bounds is None:
|
|
||||||
raise InvalidDateQuery(field, value)
|
|
||||||
return _field_range_from_dates(field, bounds[0], bounds[1], tz)
|
|
||||||
if regex.fullmatch(r"\d{14}", value):
|
|
||||||
try:
|
|
||||||
dt = datetime(
|
|
||||||
int(value[0:4]),
|
|
||||||
int(value[4:6]),
|
|
||||||
int(value[6:8]),
|
|
||||||
int(value[8:10]),
|
|
||||||
int(value[10:12]),
|
|
||||||
int(value[12:14]),
|
|
||||||
tzinfo=UTC,
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
raise InvalidDateQuery(field, value) from None
|
|
||||||
iso = _fmt(dt)
|
|
||||||
return f"{field}:[{iso} TO {iso}]"
|
|
||||||
# Unrecognized shape -> tell the user their date is malformed rather than
|
|
||||||
# silently matching nothing or emitting invalid Tantivy syntax.
|
|
||||||
raise InvalidDateQuery(field, value)
|
|
||||||
|
|
||||||
|
|
||||||
# Open-bound sentinels for date ranges. These far-past/far-future strings allow
|
|
||||||
# open-ended ranges to be expressed as Tantivy string queries until tantivy-py
|
|
||||||
# exposes Query.range_query(..., None) on Date fields (see module TODO).
|
|
||||||
OPEN_LO = "0001-01-01T00:00:00Z"
|
|
||||||
OPEN_HI = "9999-12-31T23:59:59Z"
|
|
||||||
|
|
||||||
|
|
||||||
# Matches compact now-offset tokens like now-7d, now+1h, now-30m.
|
|
||||||
_NOW_COMPACT_RE = regex.compile(
|
|
||||||
r"^now(?P<sign>[+-])(?P<n>\d+)(?P<unit>[dhm])$",
|
|
||||||
regex.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Matches "±N <unit>" Whoosh-style offsets (e.g. -7 days, -1 week, +3 hours).
|
|
||||||
# Whoosh's own date parser (qparser.dateparse.PlusMinus) additionally accepted
|
|
||||||
# abbreviated unit spellings (e.g. "yrs", "yr", "y", "mos", "wks", "hrs", "mins",
|
|
||||||
# "secs"); saved views/searches created under the old Whoosh backend can still
|
|
||||||
# contain those tokens (e.g. "-999yrs"), so they are accepted here too and
|
|
||||||
# normalized to a canonical unit via _UNIT_ALIASES below.
|
|
||||||
_NOW_SPACED_RE = regex.compile(
|
|
||||||
r"^(?P<sign>[+-])(?P<n>\d+)\s*"
|
|
||||||
r"(?P<unit>years|year|yrs|yr|ys|y"
|
|
||||||
r"|months|month|mons|mon|mos|mo"
|
|
||||||
r"|weeks|week|wks|wk|ws|w"
|
|
||||||
r"|days|day|dys|dy|ds|d"
|
|
||||||
r"|hours|hour|hrs|hr|hs|h"
|
|
||||||
r"|minutes|minute|mins|min|ms|m"
|
|
||||||
r"|seconds|second|secs|sec|s)$",
|
|
||||||
regex.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Maps every accepted unit spelling (including Whoosh-era abbreviations) to the
|
|
||||||
# canonical unit name used as a key into the delta map in _resolve_relative_bound.
|
|
||||||
_UNIT_ALIASES: dict[str, str] = {
|
|
||||||
alias: canonical
|
|
||||||
for canonical, aliases in {
|
|
||||||
"year": ("years", "year", "yrs", "yr", "ys", "y"),
|
|
||||||
"month": ("months", "month", "mons", "mon", "mos", "mo"),
|
|
||||||
"week": ("weeks", "week", "wks", "wk", "ws", "w"),
|
|
||||||
"day": ("days", "day", "dys", "dy", "ds", "d"),
|
|
||||||
"hour": ("hours", "hour", "hrs", "hr", "hs", "h"),
|
|
||||||
"minute": ("minutes", "minute", "mins", "min", "ms", "m"),
|
|
||||||
"second": ("seconds", "second", "secs", "sec", "s"),
|
|
||||||
}.items()
|
|
||||||
for alias in aliases
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_relative_bound(token: str) -> datetime | None:
|
|
||||||
"""
|
|
||||||
Resolve a relative bound token to an exact UTC instant, or return None.
|
|
||||||
|
|
||||||
Supported forms:
|
|
||||||
- ``now`` -> current UTC instant
|
|
||||||
- ``now+/-<n>d/h/m`` -> now +/- timedelta (d=days, h=hours, m=minutes)
|
|
||||||
- ``±N <unit>`` -> now +/- delta; month/year use relativedelta;
|
|
||||||
unit also accepts Whoosh-era abbreviations
|
|
||||||
(e.g. "yrs", "mos", "wks", "hrs", "mins", "secs")
|
|
||||||
"""
|
|
||||||
stripped = token.strip()
|
|
||||||
low = stripped.lower()
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
|
|
||||||
if low == "now":
|
|
||||||
return now
|
|
||||||
|
|
||||||
m = _NOW_COMPACT_RE.match(stripped)
|
|
||||||
if m:
|
|
||||||
sign = 1 if m.group("sign") == "+" else -1
|
|
||||||
n = int(m.group("n"))
|
|
||||||
unit = m.group("unit").lower()
|
|
||||||
delta = (
|
|
||||||
sign
|
|
||||||
* {
|
|
||||||
"d": timedelta(days=n),
|
|
||||||
"h": timedelta(hours=n),
|
|
||||||
"m": timedelta(minutes=n),
|
|
||||||
}[unit]
|
|
||||||
)
|
|
||||||
return now + delta
|
|
||||||
|
|
||||||
m = _NOW_SPACED_RE.match(stripped)
|
|
||||||
if m:
|
|
||||||
sign = 1 if m.group("sign") == "+" else -1
|
|
||||||
n = int(m.group("n"))
|
|
||||||
unit = _UNIT_ALIASES[m.group("unit").lower()]
|
|
||||||
delta_map: dict[str, timedelta | relativedelta] = {
|
|
||||||
"second": timedelta(seconds=n),
|
|
||||||
"minute": timedelta(minutes=n),
|
|
||||||
"hour": timedelta(hours=n),
|
|
||||||
"day": timedelta(days=n),
|
|
||||||
"week": timedelta(weeks=n),
|
|
||||||
"month": relativedelta(months=n),
|
|
||||||
"year": relativedelta(years=n),
|
|
||||||
}
|
|
||||||
return now - delta_map[unit] if sign == -1 else now + delta_map[unit]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _bound_datetimes(
|
|
||||||
field: str,
|
|
||||||
token: str,
|
|
||||||
tz: tzinfo,
|
|
||||||
) -> tuple[datetime, datetime] | None:
|
|
||||||
"""
|
|
||||||
Return (floor_dt, ceil_dt) UTC datetimes for a single range bound token, or
|
|
||||||
None if the token is unparsable. ``now`` and relative offsets resolve to the
|
|
||||||
current instant (floor == ceil == that instant; no day-flooring).
|
|
||||||
"""
|
|
||||||
token = token.strip()
|
|
||||||
|
|
||||||
# Try relative/now forms first (before stripping hyphens which would mangle them).
|
|
||||||
rel = _resolve_relative_bound(token)
|
|
||||||
if rel is not None:
|
|
||||||
return rel, rel
|
|
||||||
|
|
||||||
# Full ISO datetime token (contains "T"): parse directly and return an exact
|
|
||||||
# instant (floor == ceil). Python 3.11+ datetime.fromisoformat accepts trailing Z.
|
|
||||||
if "T" in token:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(token)
|
|
||||||
# Ensure timezone-aware UTC result.
|
|
||||||
dt = dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC)
|
|
||||||
return dt, dt
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
digits = token.replace("-", "")
|
|
||||||
bounds = _precision_bounds(digits)
|
|
||||||
if bounds is None:
|
|
||||||
return None
|
|
||||||
start, end = bounds
|
|
||||||
return _utc_bounds_for_field(field, start, end, tz)
|
|
||||||
|
|
||||||
|
|
||||||
def _render(tok: Token, tz: tzinfo) -> str:
|
|
||||||
"""Render a single token back to a Tantivy query string fragment."""
|
|
||||||
if isinstance(tok, Passthrough):
|
|
||||||
return tok.raw
|
|
||||||
if isinstance(tok, Comma):
|
|
||||||
return " AND "
|
|
||||||
if isinstance(tok, FieldValueList):
|
|
||||||
field = FIELD_ALIASES.get(tok.field, tok.field)
|
|
||||||
return " AND ".join(f"{field}:{v}" for v in tok.values)
|
|
||||||
if isinstance(tok, FieldValue):
|
|
||||||
field = FIELD_ALIASES.get(tok.field, tok.field)
|
|
||||||
if field in DATE_FIELDS:
|
|
||||||
return translate_scalar(field, tok.value, tz)
|
|
||||||
return f"{field}:{tok.value}"
|
|
||||||
if isinstance(tok, FieldRange):
|
|
||||||
field = FIELD_ALIASES.get(tok.field, tok.field)
|
|
||||||
if field in DATE_FIELDS:
|
|
||||||
return translate_range(field, tok.lo, tok.hi, tz)
|
|
||||||
return f"{field}:{tok.open}{tok.lo} TO {tok.hi}{tok.close}"
|
|
||||||
return "" # pragma: no cover
|
|
||||||
|
|
||||||
|
|
||||||
# Post-render operator normalization patterns: collapse repeated whitespace and
|
|
||||||
# strip spaced/trailing Tantivy boolean operators that would otherwise be invalid.
|
|
||||||
_MULTI_SPACE_RE = regex.compile(r" {2,}")
|
|
||||||
_TRAILING_OP_RE = regex.compile(r"\s+[-+]+\s*$")
|
|
||||||
_SPACED_OP_RE = regex.compile(r"\s+[-+]\s+")
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_operators(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Collapse multiple spaces, strip trailing dangling operators, and replace
|
|
||||||
spaced operators (`` - `` / `` + ``) with a single space.
|
|
||||||
|
|
||||||
Applied only to Passthrough fragments (the rendered output is scanned for
|
|
||||||
operator artifacts outside bracketed ranges) via a post-render pass on the
|
|
||||||
full rendered string. This preserves date ranges (``[... TO ...]``) verbatim
|
|
||||||
while cleaning natural-language separators in the surrounding text.
|
|
||||||
"""
|
|
||||||
text = _MULTI_SPACE_RE.sub(" ", text)
|
|
||||||
text = _TRAILING_OP_RE.sub("", text).strip()
|
|
||||||
text = _SPACED_OP_RE.sub(" ", text).strip()
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def translate_query(raw: str, tz: tzinfo) -> str:
|
|
||||||
"""Translate a raw Whoosh-style query into Tantivy-compatible syntax."""
|
|
||||||
tokens = resolve_commas(scan(raw))
|
|
||||||
rendered = "".join(_render(t, tz) for t in tokens)
|
|
||||||
return _normalize_operators(rendered)
|
|
||||||
|
|
||||||
|
|
||||||
def translate_range(field: str, lo: str, hi: str, tz: tzinfo) -> str:
|
|
||||||
"""Translate a date-field ``[lo TO hi]`` range to a Tantivy ISO range string.
|
|
||||||
|
|
||||||
Handles partial-date bounds (YYYY, YYYYMM, YYYYMMDD, ISO dash variants),
|
|
||||||
open bounds (empty string -> OPEN_LO/OPEN_HI), ``now``, and reversed ranges
|
|
||||||
(swaps tokens before computing floor/ceil so the span is always correct).
|
|
||||||
"""
|
|
||||||
lo_s = lo.strip()
|
|
||||||
hi_s = hi.strip()
|
|
||||||
|
|
||||||
# Parse both bounds to (floor, ceil) pairs when present.
|
|
||||||
lo_pair: tuple[datetime, datetime] | None = None
|
|
||||||
hi_pair: tuple[datetime, datetime] | None = None
|
|
||||||
|
|
||||||
if lo_s:
|
|
||||||
lo_pair = _bound_datetimes(field, lo_s, tz)
|
|
||||||
if lo_pair is None:
|
|
||||||
raise InvalidDateQuery(field, lo_s)
|
|
||||||
if hi_s:
|
|
||||||
hi_pair = _bound_datetimes(field, hi_s, tz)
|
|
||||||
if hi_pair is None:
|
|
||||||
raise InvalidDateQuery(field, hi_s)
|
|
||||||
|
|
||||||
# Detect a reversed range: only swap when BOTH bounds are present.
|
|
||||||
if lo_pair is not None and hi_pair is not None and lo_pair[0] > hi_pair[0]:
|
|
||||||
lo_pair, hi_pair = hi_pair, lo_pair
|
|
||||||
|
|
||||||
lo_iso = _fmt(lo_pair[0]) if lo_pair is not None else OPEN_LO
|
|
||||||
|
|
||||||
# A bound resolves to (floor, ceil) where floor == ceil for an exact instant
|
|
||||||
# (a full ISO datetime, "now", or a "+/-N unit" offset) and floor != ceil for
|
|
||||||
# a coarser period token (year/month/day precision). Only the latter needs a
|
|
||||||
# half-open close: its ceil is the start of the *next* period and must be
|
|
||||||
# excluded, or that instant (e.g. the 1st of next month) wrongly matches.
|
|
||||||
if hi_pair is not None:
|
|
||||||
hi_iso = _fmt(hi_pair[1])
|
|
||||||
hi_close = "]" if hi_pair[0] == hi_pair[1] else "}"
|
|
||||||
else:
|
|
||||||
hi_iso = OPEN_HI
|
|
||||||
hi_close = "]"
|
|
||||||
|
|
||||||
return f"{field}:[{lo_iso} TO {hi_iso}{hi_close}"
|
|
||||||
+14
-281
@@ -1,10 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterable
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -26,7 +24,6 @@ from django.core.validators import MaxValueValidator
|
|||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.core.validators import integer_validator
|
from django.core.validators import integer_validator
|
||||||
from django.db import DataError
|
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
@@ -40,12 +37,12 @@ from django.utils.timezone import make_aware
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from drf_spectacular.utils import extend_schema_serializer
|
from drf_spectacular.utils import extend_schema_serializer
|
||||||
|
from drf_writable_nested.serializers import NestedUpdateMixin
|
||||||
from guardian.core import ObjectPermissionChecker
|
from guardian.core import ObjectPermissionChecker
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from guardian.utils import get_group_obj_perms_model
|
from guardian.utils import get_group_obj_perms_model
|
||||||
from guardian.utils import get_user_obj_perms_model
|
from guardian.utils import get_user_obj_perms_model
|
||||||
from rest_framework import fields
|
from rest_framework import fields
|
||||||
from rest_framework import relations
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.fields import SerializerMethodField
|
from rest_framework.fields import SerializerMethodField
|
||||||
@@ -745,100 +742,22 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
|||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
|
|
||||||
class _BatchingManyRelatedField(serializers.ManyRelatedField):
|
class CorrespondentField(serializers.PrimaryKeyRelatedField[Correspondent]):
|
||||||
"""
|
|
||||||
`ManyRelatedField.to_internal_value` resolves each id in the submitted
|
|
||||||
list with its own `child_relation.to_internal_value()` call -- one query
|
|
||||||
per item on every PATCH/PUT that sets a `many=True` relation field.
|
|
||||||
Batch-resolve them instead, falling back to the child relation's normal
|
|
||||||
(query-per-item) validation for anything that isn't a plausible int pk,
|
|
||||||
so bad input still gets the usual DRF validation error rather than being
|
|
||||||
silently dropped.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_pk(item) -> int | None:
|
|
||||||
# Excludes bool: DRF's own PrimaryKeyRelatedField rejects it too
|
|
||||||
# (True == 1 would otherwise silently match pk 1).
|
|
||||||
if isinstance(item, bool):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(item)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
|
||||||
if isinstance(data, str) or not hasattr(data, "__iter__"):
|
|
||||||
self.fail("not_a_list", input_type=type(data).__name__)
|
|
||||||
if not self.allow_empty and len(data) == 0:
|
|
||||||
self.fail("empty")
|
|
||||||
|
|
||||||
item_pks = [(item, self._normalize_pk(item)) for item in data]
|
|
||||||
candidate_pks = {pk for _, pk in item_pks if pk is not None}
|
|
||||||
|
|
||||||
# Django's IntegerFieldOverflow guard (-> EmptyResultSet, i.e. no
|
|
||||||
# match) only covers exact/gt/gte/lt/lte lookups, not `in` -- an
|
|
||||||
# out-of-range int in `pk__in=` reaches the DB driver as-is and
|
|
||||||
# raises OverflowError (SQLite) / DataError (Postgres) instead of
|
|
||||||
# cleanly matching nothing. The per-item `exact`-lookup fallback
|
|
||||||
# below IS covered, so on that failure just skip the batch and let
|
|
||||||
# every item resolve individually -- each still costs one query,
|
|
||||||
# but reports the normal validation error instead of a raw 500.
|
|
||||||
try:
|
|
||||||
resolved_by_pk = {
|
|
||||||
obj.pk: obj
|
|
||||||
for obj in self.child_relation.get_queryset().filter(
|
|
||||||
pk__in=candidate_pks,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
except (OverflowError, DataError):
|
|
||||||
resolved_by_pk = {}
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for item, pk in item_pks:
|
|
||||||
obj = resolved_by_pk.get(pk) if pk is not None else None
|
|
||||||
result.append(
|
|
||||||
obj if obj is not None else self.child_relation.to_internal_value(item),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class BatchResolvingPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
|
||||||
"""
|
|
||||||
A PrimaryKeyRelatedField whose `many=True` form (a DRF ManyRelatedField)
|
|
||||||
resolves all submitted ids with one batched query instead of one query
|
|
||||||
per id. Subclasses only need to implement `get_queryset()` as usual --
|
|
||||||
only `TagsField` is used with `many=True` today, but this is the base
|
|
||||||
for all four so the fix isn't tag-specific: if a future PR puts
|
|
||||||
`many=True` on correspondent/document_type/storage_path, it inherits the
|
|
||||||
same batching instead of reintroducing this as a new bug to rediscover.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def many_init(cls, *args, **kwargs):
|
|
||||||
list_kwargs = {"child_relation": cls(*args, **kwargs)}
|
|
||||||
for key, value in kwargs.items():
|
|
||||||
if key in relations.MANY_RELATION_KWARGS:
|
|
||||||
list_kwargs[key] = value
|
|
||||||
return _BatchingManyRelatedField(**list_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class CorrespondentField(BatchResolvingPrimaryKeyRelatedField[Correspondent]):
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Correspondent.objects.all()
|
return Correspondent.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class TagsField(BatchResolvingPrimaryKeyRelatedField[Tag]):
|
class TagsField(serializers.PrimaryKeyRelatedField[Tag]):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Tag.objects.all()
|
return Tag.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class DocumentTypeField(BatchResolvingPrimaryKeyRelatedField[DocumentType]):
|
class DocumentTypeField(serializers.PrimaryKeyRelatedField[DocumentType]):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return DocumentType.objects.all()
|
return DocumentType.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class StoragePathField(BatchResolvingPrimaryKeyRelatedField[StoragePath]):
|
class StoragePathField(serializers.PrimaryKeyRelatedField[StoragePath]):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return StoragePath.objects.all()
|
return StoragePath.objects.all()
|
||||||
|
|
||||||
@@ -957,106 +876,8 @@ def validate_documentlink_targets(user, doc_ids):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# A CustomField lookup cache scoped to a single field/serializer instance
|
|
||||||
# only helps within that one instance's own validation pass. Several call
|
|
||||||
# sites, though, build more than one CustomFieldInstanceSerializer (or its
|
|
||||||
# CustomFieldInstanceListSerializer/field) for the same request and pass
|
|
||||||
# each of them `context=self.context` -- the *same* dict object, not a
|
|
||||||
# copy -- e.g. bulk-edit's _validate_custom_field_values() constructing a
|
|
||||||
# fresh CustomFieldInstanceSerializer per submitted field. That context
|
|
||||||
# dict is already request-scoped (DRF builds it fresh per request via
|
|
||||||
# get_serializer_context()), so stashing the resolved CustomField objects
|
|
||||||
# there -- rather than in some new global/thread-local cache -- lets every
|
|
||||||
# one of those separately-instantiated serializers reuse them for free
|
|
||||||
# while staying entirely within DRF's existing, already-request-scoped
|
|
||||||
# machinery.
|
|
||||||
_CUSTOM_FIELD_CONTEXT_CACHE_KEY = "_custom_field_lookup_cache"
|
|
||||||
|
|
||||||
|
|
||||||
class _CachingCustomFieldPrimaryKeyField(serializers.PrimaryKeyRelatedField):
|
|
||||||
"""
|
|
||||||
Resolves CustomField ids with as few queries as possible: a per-instance
|
|
||||||
cache for repeat lookups on this exact field instance, backed by a
|
|
||||||
shared cache on the serializer context (see _CUSTOM_FIELD_CONTEXT_CACHE_KEY
|
|
||||||
above) so later, separately-instantiated fields for the same request
|
|
||||||
reuse what was already resolved instead of re-querying.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._cache: dict[int, CustomField] = {}
|
|
||||||
|
|
||||||
def _shared_cache(self) -> dict[int, CustomField]:
|
|
||||||
return self.context.setdefault(_CUSTOM_FIELD_CONTEXT_CACHE_KEY, {})
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_pk(data: Any) -> int | None:
|
|
||||||
"""
|
|
||||||
Returns `data` coerced to the int a valid CustomField pk would be,
|
|
||||||
or None if `data` isn't a plausible pk (wrong type, unhashable,
|
|
||||||
non-numeric, or a bool -- DRF itself rejects bools as pks since
|
|
||||||
`True == 1` would otherwise silently match). None tells callers to
|
|
||||||
leave `data` alone and let `super().to_internal_value()` report the
|
|
||||||
normal validation error instead of touching the cache/queryset with
|
|
||||||
it directly.
|
|
||||||
"""
|
|
||||||
if isinstance(data, bool):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(data)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def prefetch(self, ids: Iterable[Any]) -> None:
|
|
||||||
shared_cache = self._shared_cache()
|
|
||||||
candidates = {pk for i in ids if (pk := self._normalize_pk(i)) is not None}
|
|
||||||
missing = {
|
|
||||||
i for i in candidates if i not in self._cache and i not in shared_cache
|
|
||||||
}
|
|
||||||
if missing:
|
|
||||||
for obj in self.get_queryset().filter(pk__in=missing):
|
|
||||||
shared_cache[obj.pk] = obj
|
|
||||||
for i in candidates:
|
|
||||||
obj = shared_cache.get(i)
|
|
||||||
if obj is not None:
|
|
||||||
self._cache[i] = obj
|
|
||||||
|
|
||||||
def to_internal_value(self, data: Any) -> CustomField:
|
|
||||||
pk = self._normalize_pk(data)
|
|
||||||
if pk is None:
|
|
||||||
return super().to_internal_value(data)
|
|
||||||
if pk in self._cache:
|
|
||||||
return self._cache[pk]
|
|
||||||
shared_cache = self._shared_cache()
|
|
||||||
if pk in shared_cache:
|
|
||||||
obj = shared_cache[pk]
|
|
||||||
self._cache[pk] = obj
|
|
||||||
return obj
|
|
||||||
obj: CustomField = super().to_internal_value(data)
|
|
||||||
self._cache[obj.pk] = obj
|
|
||||||
shared_cache[obj.pk] = obj
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldInstanceListSerializer(serializers.ListSerializer):
|
|
||||||
def to_internal_value(self, data: Any) -> list[Any]:
|
|
||||||
if isinstance(data, list):
|
|
||||||
field_ids = []
|
|
||||||
for item in data:
|
|
||||||
if not isinstance(item, dict) or "field" not in item:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
hash(item["field"])
|
|
||||||
except TypeError:
|
|
||||||
continue
|
|
||||||
field_ids.append(item["field"])
|
|
||||||
if field_ids:
|
|
||||||
self.child.fields["field"].prefetch(field_ids)
|
|
||||||
return super().to_internal_value(data)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
|
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
|
||||||
field = _CachingCustomFieldPrimaryKeyField(queryset=CustomField.objects.all())
|
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||||
value = ReadWriteSerializerMethodField(allow_null=True)
|
value = ReadWriteSerializerMethodField(allow_null=True)
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
@@ -1157,7 +978,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomFieldInstance
|
model = CustomFieldInstance
|
||||||
list_serializer_class = CustomFieldInstanceListSerializer
|
|
||||||
fields = [
|
fields = [
|
||||||
"value",
|
"value",
|
||||||
"field",
|
"field",
|
||||||
@@ -1223,6 +1043,7 @@ class DocumentVersionInfoSerializer(serializers.Serializer[_DocumentVersionInfo]
|
|||||||
)
|
)
|
||||||
class DocumentSerializer(
|
class DocumentSerializer(
|
||||||
OwnedObjectSerializer,
|
OwnedObjectSerializer,
|
||||||
|
NestedUpdateMixin,
|
||||||
DocumentUpdateFieldsModelSerializer,
|
DocumentUpdateFieldsModelSerializer,
|
||||||
):
|
):
|
||||||
correspondent = CorrespondentField(allow_null=True)
|
correspondent = CorrespondentField(allow_null=True)
|
||||||
@@ -1437,60 +1258,16 @@ class DocumentSerializer(
|
|||||||
if tag not in inbox_tags_not_being_added
|
if tag not in inbox_tags_not_being_added
|
||||||
]
|
]
|
||||||
|
|
||||||
custom_fields_data = validated_data.pop("custom_fields", None)
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
|
with set_actor(self.user):
|
||||||
actor_context = (
|
super().update(instance, validated_data)
|
||||||
set_actor(self.user)
|
else:
|
||||||
if settings.AUDIT_LOG_ENABLED
|
|
||||||
else contextlib.nullcontext()
|
|
||||||
)
|
|
||||||
with actor_context:
|
|
||||||
super().update(instance, validated_data)
|
super().update(instance, validated_data)
|
||||||
if custom_fields_data is not None:
|
|
||||||
self._sync_custom_fields(instance, custom_fields_data)
|
|
||||||
|
|
||||||
|
# hard delete custom field instances that were soft deleted
|
||||||
|
CustomFieldInstance.deleted_objects.filter(document=instance).delete()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def _sync_custom_fields(
|
|
||||||
self,
|
|
||||||
instance: Document,
|
|
||||||
custom_fields_data: list[dict],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Create/update a CustomFieldInstance for every (field, value) pair in
|
|
||||||
custom_fields_data, then hard-delete any of the document's existing
|
|
||||||
instances whose field wasn't included.
|
|
||||||
|
|
||||||
Replaces drf-writable-nested's generic
|
|
||||||
update_or_create_reverse_relations()/delete_reverse_relations_if_need():
|
|
||||||
that machinery always matched submitted items by an instance "id"
|
|
||||||
this client payload never sends, so its own pk-matching never did
|
|
||||||
anything for this field -- the real upsert semantics were always
|
|
||||||
CustomFieldInstanceSerializer.create()'s update_or_create() below.
|
|
||||||
|
|
||||||
On a partial (PATCH) update, DRF skips the "value" field's required
|
|
||||||
check on the first validation pass because it's absent entirely from
|
|
||||||
the payload item, not merely null. drf-writable-nested happened to
|
|
||||||
re-enforce that check itself, by re-validating each item against a
|
|
||||||
freshly built, non-partial child serializer before saving. Reproduce
|
|
||||||
that specific guarantee explicitly here, since CustomFieldInstance's
|
|
||||||
"value" is not optional.
|
|
||||||
"""
|
|
||||||
for item in custom_fields_data:
|
|
||||||
if "value" not in item:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
{"custom_fields": [{"value": ["This field is required."]}]},
|
|
||||||
)
|
|
||||||
|
|
||||||
kept_field_ids: set[int] = set()
|
|
||||||
serializer = CustomFieldInstanceSerializer()
|
|
||||||
for item in custom_fields_data:
|
|
||||||
kept_field_ids.add(item["field"].pk)
|
|
||||||
serializer.create({**item, "document": instance})
|
|
||||||
CustomFieldInstance.objects.filter(document=instance).exclude(
|
|
||||||
field_id__in=kept_field_ids,
|
|
||||||
).hard_delete()
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
self.truncate_content = kwargs.pop("truncate_content", False)
|
self.truncate_content = kwargs.pop("truncate_content", False)
|
||||||
|
|
||||||
@@ -2018,7 +1795,7 @@ class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
remote_ocr = serializers.BooleanField(required=False, default=False)
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BulkEditSerializer(
|
class BulkEditSerializer(
|
||||||
@@ -2360,13 +2137,6 @@ class BulkEditSerializer(
|
|||||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _validate_parameters_reprocess(self, parameters) -> None:
|
|
||||||
if "remote_ocr" in parameters:
|
|
||||||
if not isinstance(parameters["remote_ocr"], bool):
|
|
||||||
raise serializers.ValidationError("remote_ocr must be a boolean")
|
|
||||||
else:
|
|
||||||
parameters["remote_ocr"] = False
|
|
||||||
|
|
||||||
def validate_parameters_remove_password(self, parameters):
|
def validate_parameters_remove_password(self, parameters):
|
||||||
if "password" not in parameters:
|
if "password" not in parameters:
|
||||||
raise serializers.ValidationError("password not specified")
|
raise serializers.ValidationError("password not specified")
|
||||||
@@ -2431,8 +2201,6 @@ class BulkEditSerializer(
|
|||||||
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
||||||
elif method == bulk_edit.remove_password:
|
elif method == bulk_edit.remove_password:
|
||||||
self.validate_parameters_remove_password(parameters)
|
self.validate_parameters_remove_password(parameters)
|
||||||
elif method == bulk_edit.reprocess:
|
|
||||||
self._validate_parameters_reprocess(parameters)
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
@@ -3535,41 +3303,6 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
"actions",
|
"actions",
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
attrs = super().validate(attrs)
|
|
||||||
|
|
||||||
if "actions" in attrs:
|
|
||||||
has_remote_ocr_action = any(
|
|
||||||
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
|
|
||||||
for action in attrs["actions"]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
has_remote_ocr_action = self.instance is not None and (
|
|
||||||
self.instance.actions.filter(
|
|
||||||
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
||||||
).exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
if "triggers" in attrs:
|
|
||||||
has_consumption_trigger = any(
|
|
||||||
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
|
||||||
for trigger in attrs["triggers"]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
has_consumption_trigger = self.instance is not None and (
|
|
||||||
self.instance.triggers.filter(
|
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
||||||
).exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remote OCR can only work with consumption triggers
|
|
||||||
if has_remote_ocr_action and not has_consumption_trigger:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
"Remote OCR actions require a consumption started trigger",
|
|
||||||
)
|
|
||||||
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
def update_triggers_and_actions(
|
def update_triggers_and_actions(
|
||||||
self,
|
self,
|
||||||
instance: Workflow,
|
instance: Workflow,
|
||||||
|
|||||||
@@ -973,17 +973,6 @@ def run_workflows(
|
|||||||
)
|
)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
|
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
|
||||||
has_move_to_trash_action = True
|
has_move_to_trash_action = True
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.REMOTE_OCR:
|
|
||||||
if use_overrides and overrides:
|
|
||||||
overrides.remote_ocr = True
|
|
||||||
else:
|
|
||||||
# If a workflow has a consumption trigger *and* another type,
|
|
||||||
# the document has already been parsed by the time the other one fires
|
|
||||||
logger.debug(
|
|
||||||
"Remote OCR action only applies to consumption "
|
|
||||||
"triggers, ignoring",
|
|
||||||
extra={"group": logging_group},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
|
|||||||
+1
-10
@@ -67,7 +67,6 @@ from documents.utils import identity
|
|||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.workflows.utils import get_workflows_for_trigger
|
from documents.workflows.utils import get_workflows_for_trigger
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
|
||||||
from paperless.logging import consume_task_id
|
from paperless.logging import consume_task_id
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
from paperless.parsers.registry import get_parser_registry
|
from paperless.parsers.registry import get_parser_registry
|
||||||
@@ -339,17 +338,10 @@ def bulk_update_documents(document_ids) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def update_document_content_maybe_archive_file(
|
def update_document_content_maybe_archive_file(document_id) -> None:
|
||||||
document_id,
|
|
||||||
*,
|
|
||||||
remote_ocr: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Re-creates OCR content and thumbnail for a document, and archive file if
|
Re-creates OCR content and thumbnail for a document, and archive file if
|
||||||
it exists.
|
it exists.
|
||||||
|
|
||||||
Remote OCR is used only when the engine is configured to handle everything
|
|
||||||
or if explicitly asked for via ``remote_ocr``.
|
|
||||||
"""
|
"""
|
||||||
document = Document.objects.get(id=document_id)
|
document = Document.objects.get(id=document_id)
|
||||||
|
|
||||||
@@ -359,7 +351,6 @@ def update_document_content_maybe_archive_file(
|
|||||||
mime_type,
|
mime_type,
|
||||||
document.original_filename or "",
|
document.original_filename or "",
|
||||||
document.source_path,
|
document.source_path,
|
||||||
allow_remote=remote_ocr or RemoteOCRConfig().remote_ocr_by_default,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not parser_class:
|
if not parser_class:
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tantivy
|
|
||||||
|
|
||||||
from documents.search._backend import TantivyBackend
|
from documents.search._backend import TantivyBackend
|
||||||
from documents.search._backend import reset_backend
|
from documents.search._backend import reset_backend
|
||||||
from documents.search._schema import build_schema
|
|
||||||
from documents.search._tokenizer import register_tokenizers
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
@@ -35,11 +31,3 @@ def backend() -> Generator[TantivyBackend, None, None]:
|
|||||||
finally:
|
finally:
|
||||||
b.close()
|
b.close()
|
||||||
reset_backend()
|
reset_backend()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def index() -> tantivy.Index:
|
|
||||||
"""A real Tantivy index for parse-acceptance tests (module scope for speed)."""
|
|
||||||
idx = tantivy.Index(build_schema(), path=tempfile.mkdtemp())
|
|
||||||
register_tokenizers(idx, "english")
|
|
||||||
return idx
|
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"""Result-level acceptance corpus: real documents indexed via build_schema(),
|
||||||
|
real queries run through parse_user_query(), matched-document-ID sets
|
||||||
|
asserted, not intermediate ASTs or query strings. This is paperless-ngx's
|
||||||
|
analogue of whoosh-compat's own tests/emitter/test_acceptance_e2e.py.
|
||||||
|
|
||||||
|
Supersedes test_query.py's TestParseUserQuery result-level cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time_machine
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import Note
|
||||||
|
from documents.models import StoragePath
|
||||||
|
from documents.search._query import parse_user_query
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
FROZEN_NOW = datetime(2026, 6, 15, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
"""Create a Document and index it in one step, for the common case
|
||||||
|
where nothing needs to happen between the two (no related Note/
|
||||||
|
CustomFieldInstance to attach first)."""
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed_documents(backend: TantivyBackend) -> dict[str, int]:
|
||||||
|
"""Index a small fixture set, return {label: doc_id} for corpus queries."""
|
||||||
|
docs = {
|
||||||
|
"invoice_2020": _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice 2020",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="acc-invoice-2020",
|
||||||
|
archive_serial_number=100,
|
||||||
|
),
|
||||||
|
"invoice_2021": _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice 2021",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="acc-invoice-2021",
|
||||||
|
archive_serial_number=101,
|
||||||
|
),
|
||||||
|
"invoice_2023": _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice 2023",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="acc-invoice-2023",
|
||||||
|
archive_serial_number=102,
|
||||||
|
),
|
||||||
|
"receipt_2022": _index(
|
||||||
|
backend,
|
||||||
|
title="Receipt 2022",
|
||||||
|
content="receipt total due",
|
||||||
|
checksum="acc-receipt-2022",
|
||||||
|
archive_serial_number=103,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {label: doc.pk for label, doc in docs.items()}
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue13568BracketWildcard:
|
||||||
|
"""paperless-ngx#13568: title:202[0-3]* must keep its character class,
|
||||||
|
not fold to a prefix query that silently drops it."""
|
||||||
|
|
||||||
|
def test_bracket_class_wildcard_matches_only_in_range_years(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
indexed_documents: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
# [0-1] (not [0-3]) is deliberate: the fixture's four years are
|
||||||
|
# 2020/2021/2022/2023, i.e. their trailing digit is 0/1/2/3
|
||||||
|
# respectively - a [0-3] class would match all four and the test
|
||||||
|
# would pass even if the character class were silently dropped and
|
||||||
|
# folded to an unconstrained "202*" prefix. [0-1] partitions the
|
||||||
|
# fixture into a genuine in-range/out-of-range split.
|
||||||
|
matched = _matched_ids(backend, "title:202[0-1]*")
|
||||||
|
expected = {
|
||||||
|
indexed_documents["invoice_2020"],
|
||||||
|
indexed_documents["invoice_2021"],
|
||||||
|
}
|
||||||
|
assert matched == expected, (
|
||||||
|
"title:202[0-1]* must match 2020/2021 titles and exclude 2022/2023 "
|
||||||
|
"- if this matches everything, the wildcard's character class was "
|
||||||
|
"silently dropped (issue #13568's original bug)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldBoosts:
|
||||||
|
def test_title_boost_ranks_title_match_above_content_only_match(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
title_match = _index(
|
||||||
|
backend,
|
||||||
|
title="urgent",
|
||||||
|
content="nothing else relevant",
|
||||||
|
checksum="acc-boost-title",
|
||||||
|
)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="nothing",
|
||||||
|
content="urgent matter here",
|
||||||
|
checksum="acc-boost-content",
|
||||||
|
)
|
||||||
|
query = parse_user_query(backend._index, "urgent", UTC)
|
||||||
|
searcher = backend._index.searcher()
|
||||||
|
results = searcher.search(query, limit=10)
|
||||||
|
ranked_ids = [
|
||||||
|
searcher.doc(addr).to_dict()["id"][0] for _score, addr in results.hits
|
||||||
|
]
|
||||||
|
assert ranked_ids[0] == title_match.pk
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonSubpaths:
|
||||||
|
def test_notes_user_matches_document_with_that_note_author(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
alice = User.objects.create_user(username="alice")
|
||||||
|
doc_with_note = Document.objects.create(
|
||||||
|
title="Has note",
|
||||||
|
content="x",
|
||||||
|
checksum="acc-note-with",
|
||||||
|
)
|
||||||
|
Note.objects.create(document=doc_with_note, user=alice, note="reminder")
|
||||||
|
backend.add_or_update(doc_with_note)
|
||||||
|
_index(backend, title="No note", content="x", checksum="acc-note-without")
|
||||||
|
matched = _matched_ids(backend, "notes.user:alice")
|
||||||
|
assert matched == {doc_with_note.pk}
|
||||||
|
|
||||||
|
def test_custom_fields_name_and_value_combine(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Contract Number",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
other_field = CustomField.objects.create(
|
||||||
|
name="Other Field",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
matching = Document.objects.create(
|
||||||
|
title="Matching",
|
||||||
|
content="x",
|
||||||
|
checksum="acc-cf-matching",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=matching,
|
||||||
|
field=field,
|
||||||
|
value_text="policy",
|
||||||
|
)
|
||||||
|
backend.add_or_update(matching)
|
||||||
|
non_matching = Document.objects.create(
|
||||||
|
title="Non-matching",
|
||||||
|
content="x",
|
||||||
|
checksum="acc-cf-nonmatching",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=non_matching,
|
||||||
|
field=other_field,
|
||||||
|
value_text="policy",
|
||||||
|
)
|
||||||
|
backend.add_or_update(non_matching)
|
||||||
|
matched = _matched_ids(
|
||||||
|
backend,
|
||||||
|
'custom_fields.name:"Contract Number" custom_fields.value:policy',
|
||||||
|
)
|
||||||
|
assert matched == {matching.pk}
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnregisteredIdFieldFoldsToLiteralText:
|
||||||
|
"""tag_id, owner_id, etc. are intentionally excluded from the
|
||||||
|
FieldRegistry - always internal index columns, never meant to be
|
||||||
|
query-addressable. Prove an unregistered field folds to a literal
|
||||||
|
text search that matches nothing, rather than erroring."""
|
||||||
|
|
||||||
|
def test_tag_id_query_matches_nothing(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
indexed_documents: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
matched = _matched_ids(backend, "tag_id:5")
|
||||||
|
assert matched == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFuzzyBlendSurvivesWhooshGrammar:
|
||||||
|
"""A query mixing whoosh-only grammar (a date keyword) with a typo'd
|
||||||
|
free-text word must still fuzzy-match the intended document when
|
||||||
|
ADVANCED_FUZZY_SEARCH_THRESHOLD is enabled. The fuzzy clause is built
|
||||||
|
from the parsed query's free-text tokens (whoosh_compat's
|
||||||
|
free_text_tokens), never from the raw query string, so whoosh grammar
|
||||||
|
that tantivy's own parser rejects cannot knock the fuzzy clause out."""
|
||||||
|
|
||||||
|
def test_typo_fuzzy_matches_alongside_date_keyword(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings,
|
||||||
|
) -> None:
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
doc = _index(
|
||||||
|
backend,
|
||||||
|
title="Receipt March",
|
||||||
|
content="receipt total due",
|
||||||
|
checksum="fuzzy-blend-1",
|
||||||
|
archive_serial_number=900,
|
||||||
|
)
|
||||||
|
# Sanity: the exact spelling matches through the exact clause.
|
||||||
|
assert doc.pk in _matched_ids(backend, "added:today receipt")
|
||||||
|
# The regression: the misspelling (one transposition) only
|
||||||
|
# matches via the fuzzy clause, and "added:today" is
|
||||||
|
# whoosh-only grammar tantivy's parser rejects, so raw-string
|
||||||
|
# fuzzy parsing skips the clause entirely and this returns
|
||||||
|
# nothing. The typo is deliberate; keep codespell away from it.
|
||||||
|
typo_query = "added:today reciept" # codespell:ignore reciept
|
||||||
|
assert doc.pk in _matched_ids(backend, typo_query)
|
||||||
|
|
||||||
|
def test_negated_words_do_not_fuzzy_match(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings,
|
||||||
|
) -> None:
|
||||||
|
# A term the user excluded must not resurface through the fuzzy
|
||||||
|
# clause. The shape is chosen so this genuinely discriminates: the
|
||||||
|
# indexed document contains the NOT'd word but NOT the positive
|
||||||
|
# word, so nothing matches the exact clause, and a fuzzy string
|
||||||
|
# naively built from ALL words (including the NOT'd one) would
|
||||||
|
# make this document the sole hit, normalize its score to 1.0,
|
||||||
|
# and survive any threshold. (A shape with an exact-matching
|
||||||
|
# sibling document does NOT discriminate: normalization ranks the
|
||||||
|
# resurfaced doc far below the exact match and the threshold cuts
|
||||||
|
# it even for a naive implementation.)
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Receipt Archive",
|
||||||
|
content="receipt archived stack",
|
||||||
|
checksum="fuzzy-blend-2",
|
||||||
|
archive_serial_number=901,
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "added:today total NOT receipt") == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnquotedDateKeywordPhrases:
|
||||||
|
"""The unquoted spelling (added:previous month) is honored natively by
|
||||||
|
whoosh-compat's own grammar for this closed phrase vocabulary, no
|
||||||
|
app-level rewrite is involved. Pins that the historically supported
|
||||||
|
spelling keeps working now that paperless no longer pre-quotes it."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def period_documents(self, backend: TantivyBackend) -> dict[str, int]:
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
in_may = _index(
|
||||||
|
backend,
|
||||||
|
title="May Doc",
|
||||||
|
content="statement",
|
||||||
|
checksum="kw-may",
|
||||||
|
archive_serial_number=910,
|
||||||
|
added=datetime(2026, 5, 20, 12, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
in_june = _index(
|
||||||
|
backend,
|
||||||
|
title="June Doc",
|
||||||
|
content="statement",
|
||||||
|
checksum="kw-june",
|
||||||
|
archive_serial_number=911,
|
||||||
|
added=datetime(2026, 6, 10, 12, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
return {"in_may": in_may.pk, "in_june": in_june.pk}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
pytest.param("added:previous month", id="unquoted"),
|
||||||
|
pytest.param('added:"previous month"', id="quoted"),
|
||||||
|
pytest.param("added:Previous Month", id="unquoted-mixed-case"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unquoted_matches_the_same_documents_as_quoted(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
period_documents: dict[str, int],
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
assert _matched_ids(backend, query) == {period_documents["in_may"]}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
pytest.param("added:this month", id="this-month"),
|
||||||
|
pytest.param("added:this year", id="this-year"),
|
||||||
|
pytest.param("added:previous week", id="previous-week"),
|
||||||
|
pytest.param("added:previous quarter", id="previous-quarter"),
|
||||||
|
pytest.param("added:previous year", id="previous-year"),
|
||||||
|
pytest.param("created:previous month", id="created-field"),
|
||||||
|
pytest.param("modified:previous month", id="modified-field"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_every_phrase_and_date_field_parses_without_error(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
period_documents: dict[str, int],
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
# The whole vocabulary times every date field must at least parse
|
||||||
|
# and search cleanly (no SearchQueryError -> no HTTP 400); exact
|
||||||
|
# window semantics are whoosh-compat's, pinned in its own suite.
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
_matched_ids(backend, query)
|
||||||
|
|
||||||
|
def test_text_field_keyword_words_are_ordinary_text(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
period_documents: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
# "previous month" after a TEXT field (or unfielded) is ordinary
|
||||||
|
# text, not a date phrase: a title actually containing the words
|
||||||
|
# matches, and the date-window documents do not.
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
wordy = _index(
|
||||||
|
backend,
|
||||||
|
title="Notes from the previous month",
|
||||||
|
content="meeting notes",
|
||||||
|
checksum="kw-text",
|
||||||
|
archive_serial_number=912,
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "title:previous month") == {wordy.pk}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldAliases:
|
||||||
|
"""type:/path: are registry aliases for document_type:/storage_path:.
|
||||||
|
The only other alias coverage is parse-shape; these prove resolution
|
||||||
|
end-to-end against a real index."""
|
||||||
|
|
||||||
|
def test_type_alias_and_canonical_name_match_the_same_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
invoice_type = DocumentType.objects.create(name="invoice")
|
||||||
|
# Discriminating shape: document_type is itself a default search
|
||||||
|
# field, so if alias resolution ever broke and "type:invoice"
|
||||||
|
# demoted to unfielded text, the token would STILL match the typed
|
||||||
|
# document through the field value. The decoy carries the query
|
||||||
|
# word in content, so a demoted search matches BOTH documents and
|
||||||
|
# the exact-set assertions fail. (The title avoids stemming to
|
||||||
|
# "type": english stems Typed -> type.)
|
||||||
|
typed = _index(
|
||||||
|
backend,
|
||||||
|
title="First",
|
||||||
|
content="quarterly statement",
|
||||||
|
checksum="alias-type-1",
|
||||||
|
document_type=invoice_type,
|
||||||
|
)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Second",
|
||||||
|
content="invoice mentioned in body",
|
||||||
|
checksum="alias-type-2",
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "type:invoice") == {typed.pk}
|
||||||
|
assert _matched_ids(backend, "document_type:invoice") == {typed.pk}
|
||||||
|
|
||||||
|
def test_path_alias_and_canonical_name_match_the_same_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
archive = StoragePath.objects.create(name="archive", path="archive/{title}")
|
||||||
|
stored = _index(
|
||||||
|
backend,
|
||||||
|
title="Stored",
|
||||||
|
content="quarterly statement",
|
||||||
|
checksum="alias-path-1",
|
||||||
|
storage_path=archive,
|
||||||
|
)
|
||||||
|
# storage_path is NOT a default search field today, so a demoted
|
||||||
|
# "path:archive" already matches nothing; the content decoy keeps
|
||||||
|
# this test discriminating even if it ever joins the defaults.
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Loose",
|
||||||
|
content="archive mentioned in body",
|
||||||
|
checksum="alias-path-2",
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "path:archive") == {stored.pk}
|
||||||
|
assert _matched_ids(backend, "storage_path:archive") == {stored.pk}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""``checksum`` wildcard patterns stay literal end to end, once user queries
|
||||||
|
route through whoosh-compat.
|
||||||
|
|
||||||
|
The registry-level fact (the pattern normalizer folds a KEYWORD pattern
|
||||||
|
rather than stemming it) is pinned on its own in
|
||||||
|
``test_keyword_pattern_literal.py``. This proves it actually reaches a real
|
||||||
|
query: ``checksum:ceded*`` must match only the document whose checksum
|
||||||
|
starts with "ceded", not the one whose checksum stems to the same run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
CEDEF00D = "cedef00ddeadbeef0123456789abcdef01234567"
|
||||||
|
CEDEDEAD = "cededeadbeef567801234567" + "89abcdef01234567"
|
||||||
|
|
||||||
|
|
||||||
|
class TestChecksumPrefixQueries:
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed(self, backend: TantivyBackend) -> None:
|
||||||
|
for i, checksum in enumerate((CEDEF00D, CEDEDEAD)):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title=f"Checksum doc {i}",
|
||||||
|
content="invoices for the quarter",
|
||||||
|
checksum=checksum,
|
||||||
|
archive_serial_number=940 + i,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
def _ids(self, backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
def test_prefix_matches_only_the_document_that_starts_with_it(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
indexed: None,
|
||||||
|
) -> None:
|
||||||
|
matched = self._ids(backend, "checksum:ceded*")
|
||||||
|
expected = Document.objects.get(checksum=CEDEDEAD).pk
|
||||||
|
assert matched == {expected}
|
||||||
|
|
||||||
|
def test_text_prefix_still_reaches_the_stemmed_index(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
indexed: None,
|
||||||
|
) -> None:
|
||||||
|
assert len(self._ids(backend, "invoice*")) == 2
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""The CJK bigram clause blended into QUERY-mode searches.
|
||||||
|
|
||||||
|
The clause exists so CJK runs are matchable at all (the default analyzers
|
||||||
|
keep a whitespace-free CJK run as one indivisible token), but it must not
|
||||||
|
widen the query beyond what the user asked for: a CJK term the query
|
||||||
|
excludes, or restricts to one field, must not come back through it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestCjkClauseFollowsTheParsedQuery:
|
||||||
|
def test_negated_cjk_term_is_excluded(self, backend: TantivyBackend) -> None:
|
||||||
|
"""'invoice NOT 漢字' must not return the document containing 漢字."""
|
||||||
|
with_cjk = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice A",
|
||||||
|
content="invoice total 漢字",
|
||||||
|
checksum="cjk-neg-1",
|
||||||
|
)
|
||||||
|
without_cjk = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice B",
|
||||||
|
content="invoice total only",
|
||||||
|
checksum="cjk-neg-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "invoice") == {with_cjk.pk, without_cjk.pk}
|
||||||
|
assert _matched_ids(backend, "invoice NOT 漢字") == {without_cjk.pk}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("threshold", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(None, {"titled"}, id="fuzzy_off"),
|
||||||
|
pytest.param(0.0, {"titled", "content_only"}, id="fuzzy_on"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_fielded_cjk_term_searches_only_that_field(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
threshold: float | None,
|
||||||
|
expected: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""'title:東京' must not match a document whose 東京 is in the content.
|
||||||
|
|
||||||
|
The CJK clause honours the field. The fuzzy clause, when enabled,
|
||||||
|
does not: it contributes every free-text term UNFIELDED by design
|
||||||
|
(see _try_parse_fuzzy_query), so it brings the content-only
|
||||||
|
document back on its own 0.1-boosted terms. That is the documented
|
||||||
|
trade-off, pinned here so it stays deliberate.
|
||||||
|
"""
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = threshold
|
||||||
|
content_only = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo report",
|
||||||
|
content="東京都の人口は約1400万人です",
|
||||||
|
checksum="cjk-field-1",
|
||||||
|
)
|
||||||
|
titled = _index(
|
||||||
|
backend,
|
||||||
|
title="東京都の報告書",
|
||||||
|
content="an english summary",
|
||||||
|
checksum="cjk-field-2",
|
||||||
|
)
|
||||||
|
pks = {"titled": titled.pk, "content_only": content_only.pk}
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "東京") == set(pks.values())
|
||||||
|
assert _matched_ids(backend, "title:東京") == {pks[label] for label in expected}
|
||||||
|
|
||||||
|
def test_cjk_on_a_non_default_field_builds_no_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""A CJK term restricted to a field outside the default search fields
|
||||||
|
has nothing to contribute to the bigram clause: 'notes:東京' must not
|
||||||
|
fall back to matching 東京 in the content."""
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo report",
|
||||||
|
content="東京都の人口は約1400万人です",
|
||||||
|
checksum="cjk-notes-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "notes:東京") == set()
|
||||||
|
|
||||||
|
def test_bare_cjk_term_still_matches_every_default_field(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""The clause's reason for existing: an unfielded CJK run matches
|
||||||
|
wherever it is indexed, and does so alongside a latin term."""
|
||||||
|
in_content = _index(
|
||||||
|
backend,
|
||||||
|
title="report",
|
||||||
|
content="本文に重要な情報",
|
||||||
|
checksum="cjk-bare-1",
|
||||||
|
)
|
||||||
|
in_title = _index(
|
||||||
|
backend,
|
||||||
|
title="重要な報告書",
|
||||||
|
content="english only",
|
||||||
|
checksum="cjk-bare-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "重要") == {in_content.pk, in_title.pk}
|
||||||
|
assert _matched_ids(backend, "重要 OR report") == {
|
||||||
|
in_content.pk,
|
||||||
|
in_title.pk,
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Pins the correctness gained by deleting the pre-parse
|
||||||
|
_quote_date_keyword_phrases rewrite.
|
||||||
|
|
||||||
|
That rewrite matched date-keyword phrases (e.g. "previous month" after a
|
||||||
|
date field) anywhere in the raw query string, including inside an
|
||||||
|
unrelated quoted string, and inserted quotes mid-phrase there too. Its
|
||||||
|
own docstring gave ``title:"see added:previous month notes"`` as the
|
||||||
|
example of what it corrupted. whoosh-compat's grammar accepts the same
|
||||||
|
phrase vocabulary unquoted natively (see TestUnquotedDateKeywordPhrases
|
||||||
|
in test_acceptance.py), so the rewrite was redundant everywhere it was
|
||||||
|
safe and actively wrong everywhere it was not. This is the one case that
|
||||||
|
tells the two apart: a literal title phrase that happens to contain
|
||||||
|
"added:previous month" as running text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuotedStringContainingDateKeywordText:
|
||||||
|
"""A quoted title phrase containing the literal text
|
||||||
|
"added:previous month" as running words must match on that literal
|
||||||
|
text alone, never spill into an unfielded search for "previous" and
|
||||||
|
"month" across the default search fields the way the deleted rewrite
|
||||||
|
would have decomposed it into."""
|
||||||
|
|
||||||
|
def test_matches_only_the_literal_phrase(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
literal = _index(
|
||||||
|
backend,
|
||||||
|
title="see added:previous month notes",
|
||||||
|
content="quarterly filing",
|
||||||
|
checksum="dkp-literal",
|
||||||
|
archive_serial_number=920,
|
||||||
|
)
|
||||||
|
# Under the deleted rewrite, this decoy would incorrectly match:
|
||||||
|
# its title contains the "see added:" and " notes" fragments the
|
||||||
|
# corrupted parse required as title phrases, and its content
|
||||||
|
# supplies "previous" and "month" as the decomposed word-match
|
||||||
|
# clauses the rewrite turned the middle of the phrase into.
|
||||||
|
decoy = _index(
|
||||||
|
backend,
|
||||||
|
title="see added: quarterly report notes",
|
||||||
|
content="we reviewed the previous statement about month end",
|
||||||
|
checksum="dkp-decoy",
|
||||||
|
archive_serial_number=921,
|
||||||
|
)
|
||||||
|
query = 'title:"see added:previous month notes"'
|
||||||
|
assert _matched_ids(backend, query) == {literal.pk}
|
||||||
|
assert decoy.pk not in _matched_ids(backend, query)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Date keyword phrases (``today``, etc.) resolved in a non-UTC timezone,
|
||||||
|
end to end.
|
||||||
|
|
||||||
|
paperless's own ``tz=get_current_timezone()`` plumbing
|
||||||
|
(``TantivyBackend._parse_query``) is exercised elsewhere only for
|
||||||
|
relative *ranges* (``added:[-1 week to now]``, in
|
||||||
|
documents/tests/test_api_search.py). This covers a date *keyword*
|
||||||
|
(``today``), whose day boundary depends on the active timezone the same
|
||||||
|
way but goes through whoosh-compat's DateParserPlugin resolution instead
|
||||||
|
of an explicit range.
|
||||||
|
|
||||||
|
Discriminating shape: frozen at 2026-06-15T02:00 UTC, which is
|
||||||
|
2026-06-14T22:00 in America/New_York -- still "today" (06-14) there, but
|
||||||
|
already "today" (06-15) in UTC. Two documents pin both directions of the
|
||||||
|
mistake a hardcoded-UTC bug would make:
|
||||||
|
|
||||||
|
- ``in_ny_today`` (added 2026-06-14T20:00 UTC = 2026-06-14T16:00 NY) is
|
||||||
|
inside New York's "today" window and outside a naive UTC-calendar-day
|
||||||
|
window. A ``tz``-ignoring bug would miss it.
|
||||||
|
- ``in_utc_calendar_day_only`` (added 2026-06-15T10:00 UTC =
|
||||||
|
2026-06-15T06:00 NY) is inside a naive UTC-calendar-day window but
|
||||||
|
outside New York's actual "today" window. A ``tz``-ignoring bug would
|
||||||
|
wrongly match it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time_machine
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
FROZEN_NOW = datetime(2026, 6, 15, 2, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateKeywordUsesTheActiveTimezone:
|
||||||
|
def test_today_matches_the_new_york_calendar_day_not_the_utc_one(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.TIME_ZONE = "America/New_York"
|
||||||
|
with time_machine.travel(FROZEN_NOW, tick=False):
|
||||||
|
in_ny_today = _index(
|
||||||
|
backend,
|
||||||
|
title="NY today",
|
||||||
|
content="x",
|
||||||
|
checksum="tz-keyword-ny-today",
|
||||||
|
added=datetime(2026, 6, 14, 20, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
# Not captured: the exact-set assertion below already proves
|
||||||
|
# this document (inside a naive UTC-calendar-day window, but
|
||||||
|
# outside New York's actual "today") does not match.
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="UTC calendar day only",
|
||||||
|
content="x",
|
||||||
|
checksum="tz-keyword-utc-calendar-day-only",
|
||||||
|
added=datetime(2026, 6, 15, 10, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "added:today") == {in_ny_today.pk}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""``_DEFAULT_SEARCH_FIELDS`` must stay a subset of the registered public
|
||||||
|
field names.
|
||||||
|
|
||||||
|
Nothing enforced this before: a rename in PUBLIC_FIELDS not mirrored in
|
||||||
|
``_DEFAULT_SEARCH_FIELDS`` (documents/search/_query.py) would 400 every
|
||||||
|
unfielded search at request time, since ``index.parse_query`` and the
|
||||||
|
fuzzy/CJK clause builders are handed a field name the schema no longer
|
||||||
|
has.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
from documents.search._query import _DEFAULT_SEARCH_FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultSearchFieldsAreRegistered:
|
||||||
|
def test_every_default_search_field_is_a_public_field(self) -> None:
|
||||||
|
public_field_names = {f.name for f in PUBLIC_FIELDS}
|
||||||
|
assert set(_DEFAULT_SEARCH_FIELDS) <= public_field_names
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""Diagnostics route by Cause, and user-facing messages are host-owned.
|
||||||
|
|
||||||
|
whoosh-compat documents ``Diagnostic.message`` as developer output with no
|
||||||
|
stability guarantee, so it must never reach an HTTP response body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from whoosh_compat.errors import Diagnostic
|
||||||
|
from whoosh_compat.errors import DiagnosticKind
|
||||||
|
from whoosh_compat.errors import QueryError
|
||||||
|
from whoosh_compat.errors import cause_for
|
||||||
|
from whoosh_compat.fields import FieldKind
|
||||||
|
from whoosh_compat.fields import FieldRef
|
||||||
|
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
|
from documents.search._query import _map_emit_error
|
||||||
|
from documents.search._query import _single_diagnostic_to_error
|
||||||
|
from documents.search._query import parse_user_query
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
_LIBRARY_PROSE = "INTERNAL LIBRARY WORDING WITH raw tantivy detail"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def query_index() -> tantivy.Index:
|
||||||
|
"""An in-memory, unstemmed index; these tests only parse, never index."""
|
||||||
|
idx = tantivy.Index(build_schema(), path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic(
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
*,
|
||||||
|
field: FieldRef | None = FieldRef("title"),
|
||||||
|
field_kind: FieldKind | None = FieldKind.TEXT,
|
||||||
|
) -> Diagnostic:
|
||||||
|
"""A Diagnostic shaped like the emitter's, with the library's own
|
||||||
|
kind -> cause mapping rather than a hand-picked cause."""
|
||||||
|
return Diagnostic(
|
||||||
|
kind=kind,
|
||||||
|
cause=cause_for(kind),
|
||||||
|
message=_LIBRARY_PROSE,
|
||||||
|
field=field,
|
||||||
|
field_kind=field_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitErrorRouting:
|
||||||
|
"""Every Cause gets a distinguishable treatment, not just "a 400"."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
DiagnosticKind.BACKEND_REJECTED,
|
||||||
|
DiagnosticKind.AST_INVALID_SHAPE,
|
||||||
|
DiagnosticKind.AST_UNKNOWN_FIELD,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_internal_cause_is_not_converted(self, kind: DiagnosticKind) -> None:
|
||||||
|
"""A library defect must surface as a 500 monitoring can see, not a
|
||||||
|
400 blaming the user."""
|
||||||
|
error = QueryError(_diagnostic(kind))
|
||||||
|
with pytest.raises(QueryError) as excinfo:
|
||||||
|
_map_emit_error(error)
|
||||||
|
assert excinfo.value is error
|
||||||
|
|
||||||
|
def test_misconfigured_cause_is_logged_and_becomes_a_400(
|
||||||
|
self,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
kind = DiagnosticKind.SCHEMA_FIELD_MISSING
|
||||||
|
with caplog.at_level(logging.ERROR, logger="paperless.search"):
|
||||||
|
error = _map_emit_error(
|
||||||
|
QueryError(_diagnostic(kind, field=FieldRef("asn"))),
|
||||||
|
)
|
||||||
|
assert isinstance(error, SearchQueryError)
|
||||||
|
errors = [r for r in caplog.records if r.levelno == logging.ERROR]
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "asn" in errors[0].getMessage()
|
||||||
|
assert kind.name in errors[0].getMessage()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
DiagnosticKind.TEXT_RANGE,
|
||||||
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
||||||
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unsupported_cause_is_a_400_with_no_operator_log(
|
||||||
|
self,
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""A query tantivy cannot run is the user's to fix; it must not page
|
||||||
|
an operator the way a registry/schema mismatch does.
|
||||||
|
|
||||||
|
EXISTS_REQUIRES_FAST is nominally MISCONFIGURED but belongs here: it
|
||||||
|
is decided from the registry's own FieldSpec, so it never reports a
|
||||||
|
disagreement anyone could resolve."""
|
||||||
|
with caplog.at_level(logging.WARNING, logger="paperless.search"):
|
||||||
|
error = _map_emit_error(QueryError(_diagnostic(kind)))
|
||||||
|
assert isinstance(error, SearchQueryError)
|
||||||
|
assert caplog.records == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
DiagnosticKind.TEXT_RANGE,
|
||||||
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
||||||
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
||||||
|
DiagnosticKind.SCHEMA_FIELD_MISSING,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_user_facing_message_never_echoes_library_prose(
|
||||||
|
self,
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
) -> None:
|
||||||
|
error = _map_emit_error(QueryError(_diagnostic(kind)))
|
||||||
|
assert _LIBRARY_PROSE not in str(error)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind",
|
||||||
|
[
|
||||||
|
DiagnosticKind.TEXT_RANGE,
|
||||||
|
DiagnosticKind.PATTERN_TOO_COMPLEX,
|
||||||
|
DiagnosticKind.EXISTS_REQUIRES_FAST,
|
||||||
|
DiagnosticKind.SCHEMA_FIELD_MISSING,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_user_facing_message_names_the_field(
|
||||||
|
self,
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
) -> None:
|
||||||
|
"""FieldRef.__str__ yields the canonical dotted name, including a
|
||||||
|
JSON subpath, so every user-reachable emit kind can name it."""
|
||||||
|
diagnostic = _diagnostic(
|
||||||
|
kind,
|
||||||
|
field=FieldRef("custom_fields", "value"),
|
||||||
|
field_kind=FieldKind.JSON,
|
||||||
|
)
|
||||||
|
error = _map_emit_error(QueryError(diagnostic))
|
||||||
|
assert "custom_fields.value" in str(error)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDiagnosticMessages:
|
||||||
|
"""Parse-time diagnostics are host-worded too, off field_kind."""
|
||||||
|
|
||||||
|
def test_too_deep_is_a_400_without_library_prose(self) -> None:
|
||||||
|
error = _single_diagnostic_to_error(
|
||||||
|
_diagnostic(DiagnosticKind.TOO_DEEP, field=None, field_kind=None),
|
||||||
|
)
|
||||||
|
assert isinstance(error, SearchQueryError)
|
||||||
|
assert _LIBRARY_PROSE not in str(error)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kind", "field_kind"),
|
||||||
|
[
|
||||||
|
(DiagnosticKind.PATTERN_ON_NUMERIC, FieldKind.U64),
|
||||||
|
(DiagnosticKind.PATTERN_ON_BOOLEAN_EXISTS, FieldKind.BOOLEAN_EXISTS),
|
||||||
|
(DiagnosticKind.PATTERN_ON_SUBPATH, FieldKind.JSON),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pattern_on_kinds_name_the_field_and_its_kind(
|
||||||
|
self,
|
||||||
|
kind: DiagnosticKind,
|
||||||
|
field_kind: FieldKind,
|
||||||
|
) -> None:
|
||||||
|
error = _single_diagnostic_to_error(
|
||||||
|
_diagnostic(kind, field=FieldRef("asn"), field_kind=field_kind),
|
||||||
|
)
|
||||||
|
message = str(error)
|
||||||
|
assert _LIBRARY_PROSE not in message
|
||||||
|
assert "asn" in message
|
||||||
|
assert field_kind.name.lower() in message
|
||||||
|
|
||||||
|
def test_single_char_bracket_range_names_the_field_and_the_value(self) -> None:
|
||||||
|
diagnostic = Diagnostic(
|
||||||
|
kind=DiagnosticKind.SINGLE_CHAR_BRACKET_RANGE,
|
||||||
|
cause=cause_for(DiagnosticKind.SINGLE_CHAR_BRACKET_RANGE),
|
||||||
|
message=_LIBRARY_PROSE,
|
||||||
|
field=FieldRef("title"),
|
||||||
|
field_kind=FieldKind.TEXT,
|
||||||
|
raw_value="200[1-9]",
|
||||||
|
)
|
||||||
|
error = _single_diagnostic_to_error(diagnostic)
|
||||||
|
message = str(error)
|
||||||
|
assert isinstance(error, SearchQueryError)
|
||||||
|
assert _LIBRARY_PROSE not in message
|
||||||
|
assert "title" in message
|
||||||
|
assert "200[1-9]" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealQueriesRouteCorrectly:
|
||||||
|
"""The routing table against diagnostics emit() really produces."""
|
||||||
|
|
||||||
|
def test_text_range_is_a_400_naming_the_field(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(SearchQueryError) as excinfo:
|
||||||
|
parse_user_query(query_index, "title:[a to b]", UTC)
|
||||||
|
assert "title" in str(excinfo.value)
|
||||||
|
|
||||||
|
def test_wildcard_on_a_numeric_field_is_a_400_naming_the_field(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(SearchQueryError) as excinfo:
|
||||||
|
parse_user_query(query_index, "asn:12*", UTC)
|
||||||
|
assert "asn" in str(excinfo.value)
|
||||||
|
|
||||||
|
def test_single_char_bracket_range_is_a_400_naming_field_and_value(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(SearchQueryError) as excinfo:
|
||||||
|
parse_user_query(query_index, "title:200[1-9]", UTC)
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "title" in message
|
||||||
|
assert "200[1-9]" in message
|
||||||
|
|
||||||
|
def test_internal_diagnostic_escapes_as_a_query_error(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The one case with no query text that reaches it: emit() reporting
|
||||||
|
a defect in itself must not be converted to a user-facing 400."""
|
||||||
|
import documents.search._query as query_mod
|
||||||
|
|
||||||
|
def raise_internal(*args: object, **kwargs: object) -> None:
|
||||||
|
raise QueryError(_diagnostic(DiagnosticKind.BACKEND_REJECTED))
|
||||||
|
|
||||||
|
monkeypatch.setattr(query_mod, "tantivy_emit", raise_internal)
|
||||||
|
with pytest.raises(QueryError):
|
||||||
|
parse_user_query(query_index, "invoice", UTC)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""``field:*`` on a JSON field is user error, not an operator alert.
|
||||||
|
|
||||||
|
whoosh-compat classifies EXISTS_REQUIRES_FAST as MISCONFIGURED, and
|
||||||
|
_map_emit_error used to route every MISCONFIGURED diagnostic to an ERROR log.
|
||||||
|
But the kind is decided from the registry's own FieldSpec (kind plus fast)
|
||||||
|
without consulting the index schema, and field_descriptors() builds the JSON
|
||||||
|
fields non-fast deliberately, so nothing is misconfigured and no operator
|
||||||
|
action can clear the condition. Any authenticated user could otherwise emit
|
||||||
|
ERROR lines in a loop by repeating ``notes:*``.
|
||||||
|
|
||||||
|
SCHEMA_FIELD_MISSING, the other MISCONFIGURED kind, does compare the registry
|
||||||
|
against the live schema, so it stays an ERROR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from whoosh_compat.errors import Diagnostic
|
||||||
|
from whoosh_compat.errors import DiagnosticKind
|
||||||
|
from whoosh_compat.errors import QueryError
|
||||||
|
from whoosh_compat.errors import cause_for
|
||||||
|
from whoosh_compat.fields import FieldKind
|
||||||
|
from whoosh_compat.fields import FieldRef
|
||||||
|
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
|
from documents.search._query import _map_emit_error
|
||||||
|
from documents.search._query import parse_user_query
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
# Every spelling of "does this JSON field have a value" a user can type.
|
||||||
|
EXISTS_QUERIES = [
|
||||||
|
"notes:*",
|
||||||
|
"notes.note:*",
|
||||||
|
"notes.user:*",
|
||||||
|
"custom_fields:*",
|
||||||
|
"custom_fields.name:*",
|
||||||
|
"custom_fields.value:*",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def query_index() -> tantivy.Index:
|
||||||
|
idx = tantivy.Index(build_schema(), path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonExistsIsUserError:
|
||||||
|
@pytest.mark.parametrize("query", EXISTS_QUERIES)
|
||||||
|
def test_query_is_a_400_that_emits_no_error_log(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
with caplog.at_level(logging.WARNING, logger="paperless.search"):
|
||||||
|
with pytest.raises(SearchQueryError) as excinfo:
|
||||||
|
parse_user_query(query_index, query, UTC)
|
||||||
|
assert query.split(":", maxsplit=1)[0] in str(excinfo.value)
|
||||||
|
assert [r for r in caplog.records if r.levelno >= logging.ERROR] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenuineMisconfigurationStillLogs:
|
||||||
|
def test_schema_field_missing_is_an_error_log(
|
||||||
|
self,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""The registry naming a field the index schema does not have is a
|
||||||
|
real mismatch an operator can fix, so it keeps the alert."""
|
||||||
|
kind = DiagnosticKind.SCHEMA_FIELD_MISSING
|
||||||
|
error = QueryError(
|
||||||
|
Diagnostic(
|
||||||
|
kind=kind,
|
||||||
|
cause=cause_for(kind),
|
||||||
|
message="field 'asn' is not defined in the index schema",
|
||||||
|
field=FieldRef("asn"),
|
||||||
|
field_kind=FieldKind.U64,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.ERROR, logger="paperless.search"):
|
||||||
|
mapped = _map_emit_error(error)
|
||||||
|
assert isinstance(mapped, SearchQueryError)
|
||||||
|
records = [r for r in caplog.records if r.levelno == logging.ERROR]
|
||||||
|
assert len(records) == 1
|
||||||
|
assert kind.name in records[0].getMessage()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from whoosh_compat import FieldKind
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicFields:
|
||||||
|
def test_json_fields_have_subpaths(self) -> None:
|
||||||
|
for field in PUBLIC_FIELDS:
|
||||||
|
if field.kind is FieldKind.JSON:
|
||||||
|
assert field.subpaths, f"{field.name} is JSON but has no subpaths"
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""The words the fuzzy blend clause hands back to tantivy's parser.
|
||||||
|
|
||||||
|
The clause re-parses a word string through tantivy, which analyzes it
|
||||||
|
again, so the words must be the query's raw text rather than the analyzed
|
||||||
|
text (analysis is not idempotent), and must still be split into plain
|
||||||
|
words so that hyphenated, dotted and quoted terms keep contributing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fuzzy_enabled(settings: SettingsWrapper) -> None:
|
||||||
|
"""Enable the fuzzy blend clause. The threshold doubles as a minimum
|
||||||
|
score filter, so it is set to 0.0: every hit passes and the test sees
|
||||||
|
the clause's matching behaviour, not the filter's."""
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFuzzyClauseWords:
|
||||||
|
def test_a_stemmed_word_is_not_stemmed_a_second_time(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""'universities' stems to 'univers'; feeding that back to tantivy
|
||||||
|
stems it again to 'univ', whose fuzzy prefix reaches unrelated
|
||||||
|
words. The clause must stay wide enough for a typo and no wider."""
|
||||||
|
wanted = _index(
|
||||||
|
backend,
|
||||||
|
title="A",
|
||||||
|
content="universities of europe",
|
||||||
|
checksum="fuzz-stem-1",
|
||||||
|
)
|
||||||
|
typo = _index(
|
||||||
|
backend,
|
||||||
|
title="B",
|
||||||
|
content="universties of europe",
|
||||||
|
checksum="fuzz-stem-2",
|
||||||
|
)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="C",
|
||||||
|
content="univalent chemical bonds",
|
||||||
|
checksum="fuzz-stem-3",
|
||||||
|
)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="D",
|
||||||
|
content="unicycle repair manual",
|
||||||
|
checksum="fuzz-stem-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "universities") == {wanted.pk, typo.pk}
|
||||||
|
|
||||||
|
def test_a_hyphenated_term_still_reaches_the_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""'COVID-19' is one raw token: unless it is split into words, it
|
||||||
|
carries characters the re-parse would read as grammar, is dropped,
|
||||||
|
and the whole query loses its fuzzy clause."""
|
||||||
|
misspelled = _index(
|
||||||
|
backend,
|
||||||
|
title="A",
|
||||||
|
content="covidx testing results",
|
||||||
|
checksum="fuzz-hyphen-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "COVID-19") == {misspelled.pk}
|
||||||
|
|
||||||
|
def test_a_phrase_still_reaches_the_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""A phrase is one raw token carrying a space, and is the whole
|
||||||
|
query's only free text here."""
|
||||||
|
near_miss = _index(
|
||||||
|
backend,
|
||||||
|
title="A",
|
||||||
|
content="taxation reportage weekly",
|
||||||
|
checksum="fuzz-phrase-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, '"tax reports"') == {near_miss.pk}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBooleanKeywordsInRawText:
|
||||||
|
"""Tantivy's boolean keywords are word runs, so they survive the cut
|
||||||
|
into words and its own parser reads them as grammar. Raw query text
|
||||||
|
reaches that parser with its case intact, so a quoted phrase can carry
|
||||||
|
them in."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def corpus(self, backend: TantivyBackend) -> dict[str, int]:
|
||||||
|
both = _index(
|
||||||
|
backend,
|
||||||
|
title="A",
|
||||||
|
content="taxation reportage weekly",
|
||||||
|
checksum="fuzz-kw-1",
|
||||||
|
)
|
||||||
|
tax_only = _index(
|
||||||
|
backend,
|
||||||
|
title="B",
|
||||||
|
content="taxation only here",
|
||||||
|
checksum="fuzz-kw-2",
|
||||||
|
)
|
||||||
|
report_only = _index(
|
||||||
|
backend,
|
||||||
|
title="C",
|
||||||
|
content="reportage only here",
|
||||||
|
checksum="fuzz-kw-3",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"both": both.pk,
|
||||||
|
"tax_only": tax_only.pk,
|
||||||
|
"report_only": report_only.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
pytest.param('"tax AND reports"', id="and"),
|
||||||
|
pytest.param('"tax OR reports"', id="or"),
|
||||||
|
pytest.param('"tax NOT reports"', id="not"),
|
||||||
|
pytest.param('"tax IN reports"', id="in"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_keyword_inside_a_phrase_stays_an_ordinary_word(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
corpus: dict[str, int],
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
"""The phrase asks for three words, so the clause must stay the
|
||||||
|
disjunction it is for '"tax reports"': AND must not turn it into a
|
||||||
|
conjunction, NOT must not give it its own exclusion, IN must not
|
||||||
|
fail the parse."""
|
||||||
|
assert _matched_ids(backend, '"tax reports"') == set(corpus.values())
|
||||||
|
assert _matched_ids(backend, query) == set(corpus.values())
|
||||||
|
|
||||||
|
def test_a_trailing_keyword_does_not_drop_the_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
corpus: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
"""'tax AND' is a syntax error to tantivy's parser, which would
|
||||||
|
cost the whole query its fuzzy clause."""
|
||||||
|
assert _matched_ids(backend, '"tax AND"') == {
|
||||||
|
corpus["both"],
|
||||||
|
corpus["tax_only"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Regression coverage for the unguarded TEXT-mode highlight query.
|
||||||
|
|
||||||
|
parse_simple_text_highlight_query re-parses simple-search tokens through
|
||||||
|
Tantivy's query-string parser to build a SnippetGenerator-compatible query.
|
||||||
|
Simple-search tokens keep arbitrary punctuation (quotes, colons, brackets,
|
||||||
|
slashes), so any token carrying Tantivy query grammar raised an unguarded
|
||||||
|
ValueError. The search itself had already succeeded by the time this ran:
|
||||||
|
only the highlight step failed, and with the DocumentViewSet.list
|
||||||
|
exception handler narrowed elsewhere on this branch, that ValueError now
|
||||||
|
reaches the client as a bare 500 rather than a 400.
|
||||||
|
|
||||||
|
Covers three angles:
|
||||||
|
- the query builder itself: quoting each token as its own escaped phrase
|
||||||
|
should let it parse instead of raising, for every failure mode a plain-
|
||||||
|
text query can trigger (syntax error, unknown field, unsupported regex).
|
||||||
|
- highlight_hits: even when a token still can't be expressed as a
|
||||||
|
highlight query, the guard must fall back to a query that still
|
||||||
|
produces usable highlight HTML, not silently empty ones.
|
||||||
|
- the real API endpoint: pinning the previously-500 status to 200.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from documents.search._backend import SearchMode
|
||||||
|
from documents.search._query import parse_simple_text_highlight_query
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
# Each spelling below trips a different Tantivy parser failure mode:
|
||||||
|
# 'a"b' -> Syntax Error (unterminated quote)
|
||||||
|
# foo:bar -> unknown field
|
||||||
|
# (a -> Syntax Error (unbalanced group)
|
||||||
|
# [a -> Syntax Error (unbalanced range)
|
||||||
|
# /a/ -> Unsupported query (regex queries disallowed)
|
||||||
|
_MALFORMED_QUERIES = [
|
||||||
|
pytest.param('a"b', id="unterminated_quote"),
|
||||||
|
pytest.param("foo:bar", id="unknown_field"),
|
||||||
|
pytest.param("(a", id="unbalanced_group"),
|
||||||
|
pytest.param("[a", id="unbalanced_range"),
|
||||||
|
pytest.param("/a/", id="unsupported_regex"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def query_index() -> tantivy.Index:
|
||||||
|
"""An in-memory, unstemmed index for parse-only tests."""
|
||||||
|
schema = build_schema()
|
||||||
|
idx = tantivy.Index(schema, path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseSimpleTextHighlightQueryDoesNotRaise:
|
||||||
|
"""The query builder itself must tolerate Tantivy syntax in its tokens."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw_query", _MALFORMED_QUERIES)
|
||||||
|
def test_malformed_token_does_not_raise(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
raw_query: str,
|
||||||
|
) -> None:
|
||||||
|
assert isinstance(
|
||||||
|
parse_simple_text_highlight_query(query_index, raw_query),
|
||||||
|
tantivy.Query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHighlightHitsProducesUsableHighlights:
|
||||||
|
"""highlight_hits must keep producing real <b>-wrapped snippet HTML for
|
||||||
|
these queries, not merely avoid raising."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw_query",
|
||||||
|
[*_MALFORMED_QUERIES, pytest.param("plain text", id="plain_text_sanity")],
|
||||||
|
)
|
||||||
|
def test_highlight_still_contains_matched_text(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
raw_query: str,
|
||||||
|
) -> None:
|
||||||
|
doc = DocumentFactory.create(
|
||||||
|
title="probe",
|
||||||
|
content=f"needle content containing {raw_query} literally here",
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
hits = backend.highlight_hits(
|
||||||
|
raw_query,
|
||||||
|
[doc.pk],
|
||||||
|
search_mode=SearchMode.TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(hits) == 1
|
||||||
|
highlights = hits[0]["highlights"]
|
||||||
|
assert "content" in highlights, (
|
||||||
|
f"Expected a content highlight for {raw_query!r}, got: {highlights!r}"
|
||||||
|
)
|
||||||
|
assert "<b>" in highlights["content"], (
|
||||||
|
f"Highlight for {raw_query!r} carries no matched-term markup: "
|
||||||
|
f"{highlights['content']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHighlightGuardDiscriminatesOnValueError:
|
||||||
|
"""The guard added to highlight_hits must catch exactly ValueError, the
|
||||||
|
same shape as the sibling notes_text guard, and let anything else
|
||||||
|
through -- so a real library defect is never mistaken for a harmless
|
||||||
|
syntax error."""
|
||||||
|
|
||||||
|
def test_non_value_error_is_not_swallowed(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_runtime_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise RuntimeError("synthetic bug, unrelated to query syntax")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_simple_text_highlight_query",
|
||||||
|
raise_runtime_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(title="probe", content="anything here")
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
backend.highlight_hits(
|
||||||
|
"anything",
|
||||||
|
[doc.pk],
|
||||||
|
search_mode=SearchMode.TEXT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_search_index")
|
||||||
|
class TestApiNoLongerReturns500:
|
||||||
|
"""Pins the actual regression: a matching TEXT-mode search whose query
|
||||||
|
string carries Tantivy syntax must return results, not a server error."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw_query", _MALFORMED_QUERIES)
|
||||||
|
def test_malformed_text_query_returns_200(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
raw_query: str,
|
||||||
|
) -> None:
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(
|
||||||
|
title="probe",
|
||||||
|
content=f"needle content containing {raw_query} literally here",
|
||||||
|
)
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
|
||||||
|
response = admin_client.get(f"/api/documents/?text={raw_query}")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
|
||||||
|
def test_plain_text_query_still_returns_200(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""Sanity check: the guard must not mask a total failure of the
|
||||||
|
ordinary highlight path."""
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(
|
||||||
|
title="probe",
|
||||||
|
content="needle content containing plain text literally here",
|
||||||
|
)
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
|
||||||
|
response = admin_client.get("/api/documents/?text=plain text")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["count"] == 1
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Bare notes:/custom_fields: prefix resolution.
|
||||||
|
|
||||||
|
"notes:foo"/"custom_fields:foo" were valid fielded searches before the
|
||||||
|
whoosh-compat migration. The registry only exposes them as JSON subpaths, so
|
||||||
|
each JSON FieldSpec declares a default subpath (SubpathSpec(default=True)):
|
||||||
|
notes: resolves to notes.note:, custom_fields: resolves to
|
||||||
|
custom_fields.value:. This replaced an earlier regex-based rewrite
|
||||||
|
(_rewrite_bare_json_field_prefixes) that ran on the raw query string before
|
||||||
|
parsing and was blind to quoting, so a phrase like
|
||||||
|
content:"payment notes: none" was silently corrupted into a notes-field
|
||||||
|
search and matched nothing. Resolving the default subpath inside the parser
|
||||||
|
instead means quoting is already understood by the time it happens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import Note
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestBareJsonFieldPrefixes:
|
||||||
|
def test_bare_notes_prefix_searches_note_text(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
alice = User.objects.create_user(username="alice")
|
||||||
|
with_note = Document.objects.create(
|
||||||
|
title="Has note",
|
||||||
|
content="x",
|
||||||
|
checksum="bare-notes-with",
|
||||||
|
)
|
||||||
|
Note.objects.create(document=with_note, user=alice, note="crocodile")
|
||||||
|
backend.add_or_update(with_note)
|
||||||
|
# This document's CONTENT contains the words a demoted text search
|
||||||
|
# would match; it must NOT match once the prefix addresses notes.
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Notes about things",
|
||||||
|
content="notes crocodile mention",
|
||||||
|
checksum="bare-notes-decoy",
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "notes:crocodile") == {with_note.pk}
|
||||||
|
|
||||||
|
def test_bare_custom_fields_prefix_searches_values(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Policy Number",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
with_value = Document.objects.create(
|
||||||
|
title="Has field",
|
||||||
|
content="x",
|
||||||
|
checksum="bare-cf-with",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=with_value,
|
||||||
|
field=field,
|
||||||
|
value_text="crocodile",
|
||||||
|
)
|
||||||
|
backend.add_or_update(with_value)
|
||||||
|
_index(
|
||||||
|
backend,
|
||||||
|
title="Custom things",
|
||||||
|
content="custom fields crocodile",
|
||||||
|
checksum="bare-cf-decoy",
|
||||||
|
)
|
||||||
|
assert _matched_ids(backend, "custom_fields:crocodile") == {with_value.pk}
|
||||||
|
|
||||||
|
def test_subpath_spellings_are_untouched(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
bob = User.objects.create_user(username="bob")
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Bob note",
|
||||||
|
content="x",
|
||||||
|
checksum="bare-subpath",
|
||||||
|
)
|
||||||
|
Note.objects.create(document=doc, user=bob, note="remark")
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
assert _matched_ids(backend, "notes.user:bob") == {doc.pk}
|
||||||
|
assert _matched_ids(backend, "notes.note:remark") == {doc.pk}
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuotedPhraseContainingNotesColonIsNotCorrupted:
|
||||||
|
"""The regex rewrite this migration removes was blind to quoting: it
|
||||||
|
matched "notes:" anywhere in the raw query string, including inside an
|
||||||
|
already-quoted phrase on an unrelated field, silently turning
|
||||||
|
content:"payment notes: none" into a notes-field search that matched
|
||||||
|
nothing. Resolving the default subpath during parsing (which is
|
||||||
|
quote-aware) fixes this."""
|
||||||
|
|
||||||
|
def test_quoted_phrase_with_notes_colon_matches_by_content(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
target = _index(
|
||||||
|
backend,
|
||||||
|
title="Statement",
|
||||||
|
content="payment notes: none",
|
||||||
|
checksum="quoted-phrase-notes-colon",
|
||||||
|
)
|
||||||
|
assert _matched_ids(
|
||||||
|
backend,
|
||||||
|
'content:"payment notes: none"',
|
||||||
|
) == {target.pk}
|
||||||
|
|
||||||
|
def test_quoted_phrase_matches_the_same_document_unquoted(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
# Same document, phrasing without the colon: this proves the fix is
|
||||||
|
# about quote-awareness, not about the words themselves being
|
||||||
|
# unsearchable.
|
||||||
|
target = _index(
|
||||||
|
backend,
|
||||||
|
title="Statement",
|
||||||
|
content="payment notes none",
|
||||||
|
checksum="quoted-phrase-no-colon",
|
||||||
|
)
|
||||||
|
assert _matched_ids(
|
||||||
|
backend,
|
||||||
|
'content:"payment notes none"',
|
||||||
|
) == {target.pk}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Every declared JSON subpath must actually be written to the index.
|
||||||
|
|
||||||
|
PUBLIC_FIELDS declares each JSON field's subpaths (e.g. ``notes`` ->
|
||||||
|
{"user", "note"}), but nothing coupled that declaration to what
|
||||||
|
``_backend.py``'s document builder actually writes into the JSON blob at
|
||||||
|
index time. A subpath declared but never written would be
|
||||||
|
queryable-but-always-empty -- syntactically valid, silently matching
|
||||||
|
nothing -- with no test failure anywhere.
|
||||||
|
|
||||||
|
This indexes one real document carrying values for every JSON field
|
||||||
|
(a Note, a CustomFieldInstance) and inspects the document's own stored
|
||||||
|
JSON payload, rather than running field-specific queries: that way a
|
||||||
|
future JSON field's subpaths are covered automatically, without a new
|
||||||
|
per-subpath query having to be added by hand each time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
|
||||||
|
from documents.models import CustomField
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import Note
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonSubpathsAreWrittenAtIndexTime:
|
||||||
|
def test_every_declared_json_subpath_appears_in_the_stored_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
user = User.objects.create_user(username="completeness-user")
|
||||||
|
field = CustomField.objects.create(
|
||||||
|
name="Completeness Field",
|
||||||
|
data_type=CustomField.FieldDataType.STRING,
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Completeness doc",
|
||||||
|
content="x",
|
||||||
|
checksum="json-subpath-completeness",
|
||||||
|
)
|
||||||
|
Note.objects.create(document=doc, user=user, note="a note")
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=field,
|
||||||
|
value_text="a value",
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
index = backend._index
|
||||||
|
searcher = index.searcher()
|
||||||
|
hits = searcher.search(
|
||||||
|
tantivy.Query.term_query(index.schema, "id", doc.pk),
|
||||||
|
limit=1,
|
||||||
|
).hits
|
||||||
|
assert hits, "the document was not indexed"
|
||||||
|
stored = searcher.doc(hits[0][1]).to_dict()
|
||||||
|
|
||||||
|
json_fields = [f for f in PUBLIC_FIELDS if f.kind is FieldKind.JSON]
|
||||||
|
assert json_fields, "no JSON fields declared - fixture is stale"
|
||||||
|
for field_spec in json_fields:
|
||||||
|
stored_values = stored.get(field_spec.name)
|
||||||
|
assert stored_values, (
|
||||||
|
f"{field_spec.name} was not written to the index at all"
|
||||||
|
)
|
||||||
|
written_keys = stored_values[0].keys()
|
||||||
|
for subpath in field_spec.subpaths:
|
||||||
|
assert subpath in written_keys, (
|
||||||
|
f"{field_spec.name}.{subpath} is declared in PUBLIC_FIELDS "
|
||||||
|
"but _backend.py's document builder never writes it - it "
|
||||||
|
"would be queryable but always empty"
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Wildcard patterns on KEYWORD fields must stay literal.
|
||||||
|
|
||||||
|
``checksum`` is the only KEYWORD field: it is indexed with the raw tokenizer,
|
||||||
|
so its terms are never lowercased, folded or stemmed. Running its wildcard
|
||||||
|
patterns through the stemming normalizer rewrote hex prefixes ("ceded" ->
|
||||||
|
"cede") and returned documents whose checksum did not start with what the user
|
||||||
|
typed, which for an identity field is a wrong answer.
|
||||||
|
|
||||||
|
This covers only the registry-level normalizer, which is all that exists to
|
||||||
|
prove at this point in the stack: user queries are not yet routed through
|
||||||
|
whoosh-compat (that lands with the query-layer PR), so the same fact proven
|
||||||
|
end to end against real indexed documents lives in
|
||||||
|
``test_checksum_prefix_queries.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.search._registry import get_field_registry
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from whoosh_compat import FieldRegistry
|
||||||
|
from whoosh_compat import PatternNormalizer
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalizer(registry: FieldRegistry, name: str) -> PatternNormalizer:
|
||||||
|
ref = registry.make_ref(name)
|
||||||
|
assert ref is not None
|
||||||
|
resolved = registry.resolve(ref)
|
||||||
|
assert resolved is not None
|
||||||
|
assert resolved.spec.pattern_normalizer is not None
|
||||||
|
return resolved.spec.pattern_normalizer
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeywordPatternNormalizer:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"run",
|
||||||
|
[
|
||||||
|
pytest.param("ceded", id="stems_to_cede"),
|
||||||
|
pytest.param("added", id="stems_to_ad"),
|
||||||
|
pytest.param("cafed", id="stems_to_cafe"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_keyword_runs_are_folded_not_stemmed(self, run: str) -> None:
|
||||||
|
"""One form, the run as typed: a KEYWORD pattern must never be widened
|
||||||
|
to a stem, which would return checksums that do not start with what
|
||||||
|
the user typed."""
|
||||||
|
normalize = _normalizer(get_field_registry("en"), "checksum")
|
||||||
|
assert normalize(run) == run
|
||||||
|
|
||||||
|
def test_text_runs_still_offer_their_stem(self) -> None:
|
||||||
|
"""A TEXT field offers the stem alongside the typed run, so a term
|
||||||
|
matching either one is reachable."""
|
||||||
|
normalize = _normalizer(get_field_registry("en"), "title")
|
||||||
|
assert tuple(normalize("Running")) == ("running", "run")
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Permission filtering must hold against the real indexed document shape.
|
||||||
|
|
||||||
|
Only three of the index's unsigned ``*_id`` columns are load-bearing:
|
||||||
|
``owner_id``, ``viewer_id`` and ``viewer_group_id``, all read by
|
||||||
|
build_permission_filter. The rest (correspondent/document_type/storage_path/tag
|
||||||
|
ids) were written on every document and read by nothing, and were dropped.
|
||||||
|
|
||||||
|
These tests index real Documents through the backend's own document builder and
|
||||||
|
assert result-level visibility per user, so a mistake about which columns are
|
||||||
|
load-bearing shows up as documents leaking across users rather than as a passing
|
||||||
|
unit test over a hand-built index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import StoragePath
|
||||||
|
from documents.models import Tag
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owner() -> User:
|
||||||
|
return User.objects.create_user(username="owner")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stranger() -> User:
|
||||||
|
return User.objects.create_user(username="stranger")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def viewer() -> User:
|
||||||
|
return User.objects.create_user(username="viewer")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def group_member() -> User:
|
||||||
|
user = User.objects.create_user(username="group_member")
|
||||||
|
user.groups.add(Group.objects.create(name="accounting"))
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionFilteringOnIndexedDocuments:
|
||||||
|
def test_unowned_document_is_visible_to_everyone(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Public Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-unowned",
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == [doc.pk]
|
||||||
|
|
||||||
|
def test_owned_document_is_visible_only_to_its_owner(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
owner: User,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Private Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-owned",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=owner) == [doc.pk]
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == []
|
||||||
|
|
||||||
|
def test_explicitly_shared_document_is_visible_to_the_viewer(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
owner: User,
|
||||||
|
viewer: User,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Shared Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-shared-user",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
assign_perm("view_document", viewer, doc)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=viewer) == [doc.pk]
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == []
|
||||||
|
|
||||||
|
def test_group_shared_document_is_visible_to_group_members(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
owner: User,
|
||||||
|
group_member: User,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Group Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-shared-group",
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
assign_perm("view_document", group_member.groups.first(), doc)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=group_member) == [doc.pk]
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == []
|
||||||
|
|
||||||
|
def test_metadata_does_not_widen_visibility(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
owner: User,
|
||||||
|
stranger: User,
|
||||||
|
) -> None:
|
||||||
|
"""A document carrying correspondent/type/storage-path/tag metadata is
|
||||||
|
still filtered by owner alone."""
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Tagged Invoice",
|
||||||
|
content="invoice total due",
|
||||||
|
checksum="perm-metadata",
|
||||||
|
owner=owner,
|
||||||
|
correspondent=Correspondent.objects.create(name="ACME"),
|
||||||
|
document_type=DocumentType.objects.create(name="Bill"),
|
||||||
|
storage_path=StoragePath.objects.create(name="Archive", path="archive/"),
|
||||||
|
)
|
||||||
|
doc.tags.add(Tag.objects.create(name="paid"))
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
|
||||||
|
assert backend.search_ids("invoice", user=owner) == [doc.pk]
|
||||||
|
assert backend.search_ids("invoice", user=stranger) == []
|
||||||
@@ -1,448 +1,96 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import tzinfo
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tantivy
|
import tantivy
|
||||||
import time_machine
|
import time_machine
|
||||||
|
|
||||||
from documents.search._dates import _date_only_range
|
from documents.search._backend import build_permission_filter
|
||||||
from documents.search._dates import _datetime_range
|
from documents.search._errors import InvalidDateQuery
|
||||||
from documents.search._query import build_permission_filter
|
from documents.search._errors import InvalidNumberQuery
|
||||||
|
from documents.search._errors import MultipleSearchQueryErrors
|
||||||
|
from documents.search._errors import SearchQueryError
|
||||||
from documents.search._query import parse_simple_text_highlight_query
|
from documents.search._query import parse_simple_text_highlight_query
|
||||||
from documents.search._query import parse_user_query
|
from documents.search._query import parse_user_query
|
||||||
from documents.search._schema import build_schema
|
from documents.search._schema import build_schema
|
||||||
from documents.search._tokenizer import register_tokenizers
|
from documents.search._tokenizer import register_tokenizers
|
||||||
from documents.search._translate import InvalidDateQuery
|
|
||||||
from documents.search._translate import translate_query
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
|
|
||||||
pytestmark = pytest.mark.search
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
EASTERN = ZoneInfo("America/New_York") # UTC-5 / UTC-4 (DST)
|
|
||||||
AUCKLAND = ZoneInfo("Pacific/Auckland") # UTC+13 in southern-hemisphere summer
|
@pytest.fixture(scope="module")
|
||||||
|
def query_index() -> tantivy.Index:
|
||||||
|
"""An in-memory, unstemmed index shared read-only across this module's
|
||||||
|
parse-only tests (none of them index documents)."""
|
||||||
|
schema = build_schema()
|
||||||
|
idx = tantivy.Index(schema, path=None)
|
||||||
|
register_tokenizers(idx, "")
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
def _range(result: str, field: str) -> tuple[str, str]:
|
@pytest.fixture(scope="module")
|
||||||
# Half-open period ranges close with "}" (exclusive); exact-instant ranges
|
def populated_index() -> tantivy.Index:
|
||||||
# (full ISO datetimes, "now", relative offsets) close with "]" (inclusive).
|
"""An index holding one document, so a query matching nothing is
|
||||||
m = re.search(rf"{field}:\[(.+?) TO (.+?)[\]}}]", result)
|
distinguishable from one matching everything."""
|
||||||
assert m, f"No range for {field!r} in: {result!r}"
|
idx = tantivy.Index(build_schema(), path=None)
|
||||||
return m.group(1), m.group(2)
|
register_tokenizers(idx, "")
|
||||||
|
writer = idx.writer()
|
||||||
|
doc = tantivy.Document()
|
||||||
|
doc.add_unsigned("id", 1)
|
||||||
|
doc.add_text("content", "needle in indexed content")
|
||||||
|
writer.add_document(doc)
|
||||||
|
writer.commit()
|
||||||
|
idx.reload()
|
||||||
|
return idx
|
||||||
|
|
||||||
|
|
||||||
class TestCreatedDateField:
|
def _highlight_hit_count(index: tantivy.Index, raw_query: str) -> int:
|
||||||
"""
|
query = parse_simple_text_highlight_query(index, raw_query)
|
||||||
created is a Django DateField: indexed as midnight UTC of the local calendar
|
return index.searcher().search(query, limit=1).count
|
||||||
date. No offset arithmetic needed - the local calendar date is what matters.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("tz", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(UTC, "2026-03-28T00:00:00Z", "2026-03-29T00:00:00Z", id="utc"),
|
|
||||||
pytest.param(
|
|
||||||
EASTERN,
|
|
||||||
"2026-03-28T00:00:00Z",
|
|
||||||
"2026-03-29T00:00:00Z",
|
|
||||||
id="eastern_same_calendar_date",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 30, tzinfo=UTC), tick=False)
|
|
||||||
def test_today(self, tz: tzinfo, expected_lo: str, expected_hi: str) -> None:
|
|
||||||
lo, hi = _range(translate_query("created:today", tz), "created")
|
|
||||||
assert lo == expected_lo
|
|
||||||
assert hi == expected_hi
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 3, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_today_auckland_ahead_of_utc(self) -> None:
|
|
||||||
# UTC 03:00 -> Auckland (UTC+13) = 16:00 same date; local date = 2026-03-28
|
|
||||||
lo, _ = _range(
|
|
||||||
translate_query("created:today", AUCKLAND),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-28T00:00:00Z"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("field", "keyword", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"yesterday",
|
|
||||||
"2026-03-27T00:00:00Z",
|
|
||||||
"2026-03-28T00:00:00Z",
|
|
||||||
id="yesterday",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"previous week",
|
|
||||||
"2026-03-16T00:00:00Z",
|
|
||||||
"2026-03-23T00:00:00Z",
|
|
||||||
id="previous_week",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"this month",
|
|
||||||
"2026-03-01T00:00:00Z",
|
|
||||||
"2026-04-01T00:00:00Z",
|
|
||||||
id="this_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"previous month",
|
|
||||||
"2026-02-01T00:00:00Z",
|
|
||||||
"2026-03-01T00:00:00Z",
|
|
||||||
id="previous_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"this year",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
"2027-01-01T00:00:00Z",
|
|
||||||
id="this_year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created",
|
|
||||||
"previous year",
|
|
||||||
"2025-01-01T00:00:00Z",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
id="previous_year",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_date_keywords(
|
|
||||||
self,
|
|
||||||
field: str,
|
|
||||||
keyword: str,
|
|
||||||
expected_lo: str,
|
|
||||||
expected_hi: str,
|
|
||||||
) -> None:
|
|
||||||
# 2026-03-28 is Saturday; Mon-Sun week calculation built into expectations
|
|
||||||
query = f"{field}:{keyword}"
|
|
||||||
lo, hi = _range(translate_query(query, UTC), field)
|
|
||||||
assert lo == expected_lo
|
|
||||||
assert hi == expected_hi
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 12, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_this_month_december_wraps_to_next_year(self) -> None:
|
|
||||||
# December: next month must roll over to January 1 of next year
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("created:this month", UTC),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2026-12-01T00:00:00Z"
|
|
||||||
assert hi == "2027-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 1, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_last_month_january_wraps_to_previous_year(self) -> None:
|
|
||||||
# January: last month must roll back to December 1 of previous year
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("created:previous month", UTC),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2025-12-01T00:00:00Z"
|
|
||||||
assert hi == "2026-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 7, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_previous_quarter(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query('created:"previous quarter"', UTC),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2026-04-01T00:00:00Z"
|
|
||||||
assert hi == "2026-07-01T00:00:00Z"
|
|
||||||
|
|
||||||
def test_unknown_keyword_raises(self) -> None:
|
|
||||||
with pytest.raises(ValueError, match="Unknown keyword"):
|
|
||||||
_date_only_range("bogus_keyword", UTC)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDateTimeFields:
|
|
||||||
"""
|
|
||||||
added/modified store full UTC datetimes. Natural keywords must convert
|
|
||||||
the local day boundaries to UTC - timezone offset arithmetic IS required.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 30, tzinfo=UTC), tick=False)
|
|
||||||
def test_added_today_eastern(self) -> None:
|
|
||||||
# EDT = UTC-4; local midnight 2026-03-28 00:00 EDT = 2026-03-28 04:00 UTC
|
|
||||||
lo, hi = _range(translate_query("added:today", EASTERN), "added")
|
|
||||||
assert lo == "2026-03-28T04:00:00Z"
|
|
||||||
assert hi == "2026-03-29T04:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 29, 2, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_added_today_auckland_midnight_crossing(self) -> None:
|
|
||||||
# UTC 02:00 on 2026-03-29 -> Auckland (UTC+13) = 2026-03-29 15:00 local
|
|
||||||
# Auckland midnight = UTC 2026-03-28 11:00
|
|
||||||
lo, hi = _range(translate_query("added:today", AUCKLAND), "added")
|
|
||||||
assert lo == "2026-03-28T11:00:00Z"
|
|
||||||
assert hi == "2026-03-29T11:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_modified_today_utc(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("modified:today", UTC),
|
|
||||||
"modified",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-28T00:00:00Z"
|
|
||||||
assert hi == "2026-03-29T00:00:00Z"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("keyword", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"yesterday",
|
|
||||||
"2026-03-27T00:00:00Z",
|
|
||||||
"2026-03-28T00:00:00Z",
|
|
||||||
id="yesterday",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous week",
|
|
||||||
"2026-03-16T00:00:00Z",
|
|
||||||
"2026-03-23T00:00:00Z",
|
|
||||||
id="previous_week",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this month",
|
|
||||||
"2026-03-01T00:00:00Z",
|
|
||||||
"2026-04-01T00:00:00Z",
|
|
||||||
id="this_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous month",
|
|
||||||
"2026-02-01T00:00:00Z",
|
|
||||||
"2026-03-01T00:00:00Z",
|
|
||||||
id="previous_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this year",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
"2027-01-01T00:00:00Z",
|
|
||||||
id="this_year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous year",
|
|
||||||
"2025-01-01T00:00:00Z",
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
id="previous_year",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_datetime_keywords_utc(
|
|
||||||
self,
|
|
||||||
keyword: str,
|
|
||||||
expected_lo: str,
|
|
||||||
expected_hi: str,
|
|
||||||
) -> None:
|
|
||||||
# 2026-03-28 is Saturday; weekday()==5 so Monday=2026-03-23
|
|
||||||
lo, hi = _range(translate_query(f"added:{keyword}", UTC), "added")
|
|
||||||
assert lo == expected_lo
|
|
||||||
assert hi == expected_hi
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 12, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_this_month_december_wraps_to_next_year(self) -> None:
|
|
||||||
# December: next month wraps to January of next year
|
|
||||||
lo, hi = _range(translate_query("added:this month", UTC), "added")
|
|
||||||
assert lo == "2026-12-01T00:00:00Z"
|
|
||||||
assert hi == "2027-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 1, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_last_month_january_wraps_to_previous_year(self) -> None:
|
|
||||||
# January: last month wraps back to December of previous year
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:previous month", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2025-12-01T00:00:00Z"
|
|
||||||
assert hi == "2026-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("query", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
'added:"previous quarter"',
|
|
||||||
"2026-04-01T00:00:00Z",
|
|
||||||
"2026-07-01T00:00:00Z",
|
|
||||||
id="quoted_previous_quarter",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"added:previous month",
|
|
||||||
"2026-06-01T00:00:00Z",
|
|
||||||
"2026-07-01T00:00:00Z",
|
|
||||||
id="bare_previous_month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"added:this month",
|
|
||||||
"2026-07-01T00:00:00Z",
|
|
||||||
"2026-08-01T00:00:00Z",
|
|
||||||
id="bare_this_month",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(datetime(2026, 7, 15, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_legacy_natural_language_aliases(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
expected_lo: str,
|
|
||||||
expected_hi: str,
|
|
||||||
) -> None:
|
|
||||||
lo, hi = _range(translate_query(query, UTC), "added")
|
|
||||||
assert lo == expected_lo
|
|
||||||
assert hi == expected_hi
|
|
||||||
|
|
||||||
def test_unknown_keyword_raises(self) -> None:
|
|
||||||
with pytest.raises(ValueError, match="Unknown keyword"):
|
|
||||||
_datetime_range("bogus_keyword", UTC)
|
|
||||||
|
|
||||||
|
|
||||||
class TestWhooshQueryRewriting:
|
|
||||||
"""All Whoosh query syntax variants must be rewritten to ISO 8601 before Tantivy parses them."""
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_compact_date_shim_rewrites_to_iso(self) -> None:
|
|
||||||
result = translate_query("created:20240115120000", UTC)
|
|
||||||
assert "2024-01-15" in result
|
|
||||||
assert "20240115120000" not in result
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 15, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_relative_range_shim_removes_now(self) -> None:
|
|
||||||
result = translate_query("added:[now-7d TO now]", UTC)
|
|
||||||
assert "now" not in result
|
|
||||||
assert "2026-03-" in result
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_minus_7_days(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:[-7 days to now]", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-21T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_minus_1_week(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:[-1 week to now]", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-21T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_minus_1_month_uses_relativedelta(self) -> None:
|
|
||||||
# relativedelta(months=1) from 2026-03-28 = 2026-02-28 (not 29)
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("created:[-1 month to now]", UTC),
|
|
||||||
"created",
|
|
||||||
)
|
|
||||||
assert lo == "2026-02-28T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_minus_1_year(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("modified:[-1 year to now]", UTC),
|
|
||||||
"modified",
|
|
||||||
)
|
|
||||||
assert lo == "2025-03-28T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_plural_unit_hours(self) -> None:
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:[-3 hours to now]", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-28T09:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_bracket_case_insensitive(self) -> None:
|
|
||||||
result = translate_query("added:[-1 WEEK TO NOW]", UTC)
|
|
||||||
assert "now" not in result.lower()
|
|
||||||
lo, hi = _range(result, "added")
|
|
||||||
assert lo == "2026-03-21T12:00:00Z"
|
|
||||||
assert hi == "2026-03-28T12:00:00Z"
|
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False)
|
|
||||||
def test_relative_range_swaps_bounds_when_lo_exceeds_hi(self) -> None:
|
|
||||||
# [now+1h TO now-1h] has lo > hi before substitution; they must be swapped
|
|
||||||
lo, hi = _range(
|
|
||||||
translate_query("added:[now+1h TO now-1h]", UTC),
|
|
||||||
"added",
|
|
||||||
)
|
|
||||||
assert lo == "2026-03-28T11:00:00Z"
|
|
||||||
assert hi == "2026-03-28T13:00:00Z"
|
|
||||||
|
|
||||||
def test_8digit_created_date_field_always_uses_utc_midnight(self) -> None:
|
|
||||||
# created is a DateField: boundaries are always UTC midnight, no TZ offset
|
|
||||||
result = translate_query("created:20231201", EASTERN)
|
|
||||||
lo, hi = _range(result, "created")
|
|
||||||
assert lo == "2023-12-01T00:00:00Z"
|
|
||||||
assert hi == "2023-12-02T00:00:00Z"
|
|
||||||
|
|
||||||
def test_8digit_added_datetime_field_converts_local_midnight_to_utc(self) -> None:
|
|
||||||
# added is DateTimeField: midnight Dec 1 Eastern (EST = UTC-5) = 05:00 UTC
|
|
||||||
result = translate_query("added:20231201", EASTERN)
|
|
||||||
lo, hi = _range(result, "added")
|
|
||||||
assert lo == "2023-12-01T05:00:00Z"
|
|
||||||
assert hi == "2023-12-02T05:00:00Z"
|
|
||||||
|
|
||||||
def test_8digit_modified_datetime_field_converts_local_midnight_to_utc(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
result = translate_query("modified:20231201", EASTERN)
|
|
||||||
lo, hi = _range(result, "modified")
|
|
||||||
assert lo == "2023-12-01T05:00:00Z"
|
|
||||||
assert hi == "2023-12-02T05:00:00Z"
|
|
||||||
|
|
||||||
def test_8digit_invalid_date_raises(self) -> None:
|
|
||||||
# The translation pipeline raises InvalidDateQuery for unparsable dates
|
|
||||||
# (e.g. month=13) so the API can surface a 400 telling the user the date
|
|
||||||
# is malformed instead of silently returning zero results.
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_query("added:20231340", UTC)
|
|
||||||
assert exc_info.value.field == "added"
|
|
||||||
assert exc_info.value.value == "20231340"
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseUserQuery:
|
class TestParseUserQuery:
|
||||||
"""parse_user_query runs the full preprocessing pipeline."""
|
"""parse_user_query runs the full preprocessing pipeline."""
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def query_index(self) -> tantivy.Index:
|
|
||||||
schema = build_schema()
|
|
||||||
idx = tantivy.Index(schema, path=None)
|
|
||||||
register_tokenizers(idx, "")
|
|
||||||
return idx
|
|
||||||
|
|
||||||
def test_returns_tantivy_query(self, query_index: tantivy.Index) -> None:
|
def test_returns_tantivy_query(self, query_index: tantivy.Index) -> None:
|
||||||
assert isinstance(parse_user_query(query_index, "invoice", UTC), tantivy.Query)
|
assert isinstance(parse_user_query(query_index, "invoice", UTC), tantivy.Query)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw_query",
|
||||||
|
[
|
||||||
|
pytest.param("invoice", id="plain_text"),
|
||||||
|
pytest.param("created:today", id="date_keyword"),
|
||||||
|
pytest.param("created:[2005 to 2009]", id="whoosh_date_range"),
|
||||||
|
pytest.param('added:"previous month"', id="quoted_date_phrase"),
|
||||||
|
pytest.param("title:202[0-1]*", id="bracket_class_wildcard"),
|
||||||
|
],
|
||||||
|
)
|
||||||
def test_fuzzy_mode_does_not_raise(
|
def test_fuzzy_mode_does_not_raise(
|
||||||
self,
|
self,
|
||||||
query_index: tantivy.Index,
|
query_index: tantivy.Index,
|
||||||
settings,
|
settings,
|
||||||
|
raw_query: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# These are all valid whoosh grammar that tantivy's own query parser
|
||||||
|
# (used only by the fuzzy blend clause) cannot parse; the fuzzy
|
||||||
|
# clause must degrade gracefully instead of raising and failing the
|
||||||
|
# whole query. See _try_parse_fuzzy_query.
|
||||||
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.5
|
||||||
assert isinstance(parse_user_query(query_index, "invoice", UTC), tantivy.Query)
|
assert isinstance(parse_user_query(query_index, raw_query, UTC), tantivy.Query)
|
||||||
|
|
||||||
def test_date_rewriting_applied_before_tantivy_parse(
|
def test_date_keyword_resolves_without_raising(
|
||||||
self,
|
self,
|
||||||
query_index: tantivy.Index,
|
query_index: tantivy.Index,
|
||||||
) -> None:
|
) -> None:
|
||||||
# created:today must be rewritten to an ISO range before Tantivy parses it;
|
# whoosh-compat's DateParserPlugin resolves "today" against the AST
|
||||||
# if passed raw, Tantivy would reject "today" as an invalid date value
|
# directly (no string rewrite to an ISO range happens anywhere in
|
||||||
|
# this pipeline); the emitted tantivy query must still build cleanly.
|
||||||
with time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False):
|
with time_machine.travel(datetime(2026, 3, 28, 12, 0, tzinfo=UTC), tick=False):
|
||||||
q = parse_user_query(query_index, "created:today", UTC)
|
q = parse_user_query(query_index, "created:today", UTC)
|
||||||
assert isinstance(q, tantivy.Query)
|
assert isinstance(q, tantivy.Query)
|
||||||
@@ -466,302 +114,58 @@ class TestParseUserQuery:
|
|||||||
) -> None:
|
) -> None:
|
||||||
assert isinstance(parse_user_query(query_index, raw_query, UTC), tantivy.Query)
|
assert isinstance(parse_user_query(query_index, raw_query, UTC), tantivy.Query)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw_query",
|
|
||||||
[
|
|
||||||
# Partial date scalar (year only)
|
|
||||||
pytest.param("created:2020", id="created_year_scalar"),
|
|
||||||
# 8-digit compact date range in brackets
|
|
||||||
pytest.param(
|
|
||||||
"created:[20200101 TO 20201231]",
|
|
||||||
id="created_8digit_bracket_range",
|
|
||||||
),
|
|
||||||
# Comma-separated field + date range (Whoosh v2 multi-clause syntax)
|
|
||||||
pytest.param(
|
|
||||||
"title:x,created:[2020 TO 2021]",
|
|
||||||
id="title_comma_created_range",
|
|
||||||
),
|
|
||||||
# Field alias: type -> document_type
|
|
||||||
pytest.param("type:invoice", id="type_alias"),
|
|
||||||
# Multi-word date keyword
|
|
||||||
pytest.param("created:previous week", id="created_previous_week"),
|
|
||||||
# Full ISO datetime range
|
|
||||||
pytest.param(
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]",
|
|
||||||
id="created_iso_range",
|
|
||||||
),
|
|
||||||
# Comma-separated ISO ranges (Whoosh v2 syntax)
|
|
||||||
pytest.param(
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z],"
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]",
|
|
||||||
id="comma_iso_ranges",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_advanced_search_queries_do_not_raise(
|
|
||||||
self,
|
|
||||||
query_index: tantivy.Index,
|
|
||||||
raw_query: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
End-to-end: queries that the frontend sends must parse without raising.
|
|
||||||
|
|
||||||
This tests the full pipeline: translate_query -> tantivy parse_query.
|
|
||||||
Equivalent to asserting HTTP 200 (not 400) for each query form.
|
|
||||||
"""
|
|
||||||
with time_machine.travel(datetime(2026, 6, 15, 12, 0, tzinfo=UTC), tick=False):
|
|
||||||
assert isinstance(
|
|
||||||
parse_user_query(query_index, raw_query, UTC),
|
|
||||||
tantivy.Query,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_invalid_date_propagates_not_swallowed(
|
def test_invalid_date_propagates_not_swallowed(
|
||||||
self,
|
self,
|
||||||
query_index: tantivy.Index,
|
query_index: tantivy.Index,
|
||||||
) -> None:
|
) -> None:
|
||||||
# parse_user_query falls back to the raw query on unexpected translation
|
# parse_user_query never falls back to the raw query string on a parse
|
||||||
# errors, but an InvalidDateQuery is intentional and must propagate so the
|
# error: a bad date diagnostic from whoosh-compat always maps to an
|
||||||
# view can return a 400 instead of silently parsing the raw (invalid) date.
|
# InvalidDateQuery and must propagate, so the view can return a 400
|
||||||
|
# instead of silently parsing the raw (invalid) date.
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
with pytest.raises(InvalidDateQuery) as exc_info:
|
||||||
parse_user_query(query_index, "created:202023", UTC)
|
parse_user_query(query_index, "created:202023", UTC)
|
||||||
assert exc_info.value.field == "created"
|
assert exc_info.value.field == "created"
|
||||||
assert exc_info.value.value == "202023"
|
assert exc_info.value.value == "202023"
|
||||||
|
|
||||||
|
def test_invalid_number_raises_invalid_number_query(
|
||||||
class TestYearRangeRewriting:
|
|
||||||
"""Whoosh-style year-only date ranges must be rewritten to ISO 8601."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("query", "field", "expected_lo", "expected_hi"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"created:[2020 TO 2020]",
|
|
||||||
"created",
|
|
||||||
"2020-01-01T00:00:00Z",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
id="single_year_created",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created:[2018 TO 2021]",
|
|
||||||
"created",
|
|
||||||
"2018-01-01T00:00:00Z",
|
|
||||||
"2022-01-01T00:00:00Z",
|
|
||||||
id="multi_year_range_created",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"added:[2022 TO 2023]",
|
|
||||||
"added",
|
|
||||||
"2022-01-01T00:00:00Z",
|
|
||||||
"2024-01-01T00:00:00Z",
|
|
||||||
id="added_field",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"modified:[2021 TO 2021]",
|
|
||||||
"modified",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
"2022-01-01T00:00:00Z",
|
|
||||||
id="modified_field",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created:[2020 to 2020]",
|
|
||||||
"created",
|
|
||||||
"2020-01-01T00:00:00Z",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
id="lowercase_to_keyword",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_year_range_rewritten(
|
|
||||||
self,
|
self,
|
||||||
query: str,
|
query_index: tantivy.Index,
|
||||||
field: str,
|
|
||||||
expected_lo: str,
|
|
||||||
expected_hi: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
result = translate_query(query, UTC)
|
with pytest.raises(InvalidNumberQuery) as exc_info:
|
||||||
lo, hi = _range(result, field)
|
parse_user_query(query_index, "asn:notanumber", UTC)
|
||||||
assert lo == expected_lo
|
assert exc_info.value.field == "asn"
|
||||||
assert hi == expected_hi
|
assert exc_info.value.value == "notanumber"
|
||||||
|
|
||||||
def test_reversed_year_range_is_swapped(self) -> None:
|
def test_multiple_bad_fields_raise_multiple_search_query_errors(
|
||||||
# A reversed range must not yield lo > hi, which Tantivy treats as an
|
|
||||||
# empty range (silently zero results). The bounds are swapped instead.
|
|
||||||
result = translate_query("created:[2025 TO 2020]", UTC)
|
|
||||||
lo, hi = _range(result, "created")
|
|
||||||
assert lo == "2020-01-01T00:00:00Z"
|
|
||||||
assert hi == "2026-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
def test_year_range_in_complex_boolean_query(self) -> None:
|
|
||||||
query = "tag:steuer AND (title:2020 OR (NOT title:2019 AND NOT title:2018 AND created:[2020 TO 2020]))"
|
|
||||||
result = translate_query(query, UTC)
|
|
||||||
lo, hi = _range(result, "created")
|
|
||||||
assert lo == "2020-01-01T00:00:00Z"
|
|
||||||
assert hi == "2021-01-01T00:00:00Z"
|
|
||||||
assert "title:2020" in result
|
|
||||||
assert "title:2019" in result
|
|
||||||
assert "title:2018" in result
|
|
||||||
|
|
||||||
def test_already_iso_date_range_passes_through_unchanged(self) -> None:
|
|
||||||
original = "created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z]"
|
|
||||||
assert translate_query(original, UTC) == original
|
|
||||||
|
|
||||||
def test_8digit_in_brackets_not_matched_as_year_range(self) -> None:
|
|
||||||
# [YYYYMMDD TO YYYYMMDD]: the translation layer converts 8-digit bounds to
|
|
||||||
# ISO day ranges. 20200101 -> 2020-01-01T00:00:00Z (lo of that day);
|
|
||||||
# 20201231 -> the ceil of Dec 31 = 2021-01-01T00:00:00Z (exclusive end).
|
|
||||||
# This is the correct and accepted behavior: old compact form becomes a
|
|
||||||
# proper Tantivy-parseable ISO range.
|
|
||||||
original = "created:[20200101 TO 20201231]"
|
|
||||||
result = translate_query(original, UTC)
|
|
||||||
lo, hi = _range(result, "created")
|
|
||||||
assert lo == "2020-01-01T00:00:00Z"
|
|
||||||
assert hi == "2021-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
|
|
||||||
class TestNonDateFieldsNotRewritten:
|
|
||||||
"""Date rewriters must only fire on the date fields (created/modified/added).
|
|
||||||
|
|
||||||
Integer fields like asn/id/page_count and unknown fields would otherwise be
|
|
||||||
rewritten into date ranges and rejected by Tantivy as type mismatches.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"query",
|
|
||||||
[
|
|
||||||
pytest.param("asn:20240101", id="asn_8digit"),
|
|
||||||
pytest.param("id:20240101", id="id_8digit"),
|
|
||||||
pytest.param("page_count:12345678", id="page_count_8digit"),
|
|
||||||
pytest.param("num_notes:20231201", id="num_notes_8digit"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_8digit_on_integer_field_passes_through_unchanged(self, query: str) -> None:
|
|
||||||
assert translate_query(query, EASTERN) == query
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"query",
|
|
||||||
[
|
|
||||||
pytest.param("asn:[2000 TO 2024]", id="asn_year_range"),
|
|
||||||
pytest.param("id:[2000 TO 2024]", id="id_year_range"),
|
|
||||||
pytest.param("page_count:[2000 TO 2024]", id="page_count_year_range"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_year_range_on_integer_field_passes_through_unchanged(
|
|
||||||
self,
|
self,
|
||||||
query: str,
|
query_index: tantivy.Index,
|
||||||
) -> None:
|
) -> None:
|
||||||
assert translate_query(query, UTC) == query
|
with pytest.raises(MultipleSearchQueryErrors) as exc_info:
|
||||||
|
parse_user_query(
|
||||||
|
query_index,
|
||||||
|
"created:notadate AND asn:notanumber",
|
||||||
|
UTC,
|
||||||
|
)
|
||||||
|
assert len(exc_info.value.errors) == 2
|
||||||
|
kinds = {type(e) for e in exc_info.value.errors}
|
||||||
|
assert kinds == {InvalidDateQuery, InvalidNumberQuery}
|
||||||
|
|
||||||
def test_unknown_field_keyword_passes_through_unchanged(self) -> None:
|
def test_unregistered_id_field_folds_to_literal_text_not_error(
|
||||||
# foobar is not a date field: 'foobar:today' must not become a date range,
|
self,
|
||||||
# which Tantivy would otherwise reject as an unknown/typed field.
|
query_index: tantivy.Index,
|
||||||
assert translate_query("foobar:today", UTC) == "foobar:today"
|
) -> None:
|
||||||
|
# tag_id is intentionally excluded from the FieldRegistry: whoosh-compat
|
||||||
|
# parity leniency folds it into literal text, not a diagnostic/400.
|
||||||
class TestPassthrough:
|
# A result-level assertion that this fold actually matches nothing
|
||||||
"""Queries without field prefixes or unrelated content pass through unchanged."""
|
# against real documents lives in
|
||||||
|
# test_acceptance.py::TestUnregisteredIdFieldFoldsToLiteralText.
|
||||||
def test_bare_keyword_no_field_prefix_unchanged(self) -> None:
|
q = parse_user_query(query_index, "tag_id:5", UTC)
|
||||||
# Bare 'today' with no field: prefix passes through unchanged
|
assert isinstance(q, tantivy.Query)
|
||||||
result = translate_query("bank statement today", UTC)
|
|
||||||
assert "today" in result
|
|
||||||
|
|
||||||
def test_unrelated_query_unchanged(self) -> None:
|
|
||||||
assert translate_query("title:invoice", UTC) == "title:invoice"
|
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeQuery:
|
|
||||||
"""translate_query expands comma-separated values and collapses whitespace."""
|
|
||||||
|
|
||||||
def test_normalize_expands_comma_separated_tags(self) -> None:
|
|
||||||
assert translate_query("tag:foo,bar", UTC) == "tag:foo AND tag:bar"
|
|
||||||
|
|
||||||
def test_normalize_comma_between_range_expressions(self) -> None:
|
|
||||||
# Comma-separated field range expressions (Whoosh v2 syntax) must be
|
|
||||||
# converted to AND so Tantivy does not receive an invalid comma.
|
|
||||||
q = "created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z],added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
assert translate_query(q, UTC) == (
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
" AND "
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_normalize_expands_three_values(self) -> None:
|
|
||||||
assert (
|
|
||||||
translate_query("tag:foo,bar,baz", UTC) == "tag:foo AND tag:bar AND tag:baz"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_normalize_collapses_whitespace(self) -> None:
|
|
||||||
assert translate_query("bank statement", UTC) == "bank statement"
|
|
||||||
|
|
||||||
def test_normalize_no_commas_unchanged(self) -> None:
|
|
||||||
assert translate_query("bank statement", UTC) == "bank statement"
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("raw", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"h52.1 - kurzsichtigkeit",
|
|
||||||
"h52.1 kurzsichtigkeit",
|
|
||||||
id="icd_code_dash_description",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"H52.1 - asd",
|
|
||||||
"H52.1 asd",
|
|
||||||
id="icd_code_uppercase_dash",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"h52.1 -",
|
|
||||||
"h52.1",
|
|
||||||
id="trailing_minus",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
". -",
|
|
||||||
".",
|
|
||||||
id="dot_trailing_minus",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"h52. -",
|
|
||||||
"h52.",
|
|
||||||
id="partial_code_trailing_minus",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"foo - bar - baz",
|
|
||||||
"foo bar baz",
|
|
||||||
id="multiple_dashes",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"foo + bar",
|
|
||||||
"foo bar",
|
|
||||||
id="spaced_plus_operator",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_normalize_strips_dangling_operators(self, raw: str, expected: str) -> None:
|
|
||||||
assert translate_query(raw, UTC) == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"query",
|
|
||||||
[
|
|
||||||
pytest.param("term -other", id="adjacent_not_operator"),
|
|
||||||
pytest.param("-term", id="leading_not_operator"),
|
|
||||||
pytest.param("+term", id="leading_must_operator"),
|
|
||||||
pytest.param("foo -bar +baz", id="mixed_adjacent_operators"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_normalize_preserves_valid_operators(self, query: str) -> None:
|
|
||||||
assert translate_query(query, UTC) == query
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseSimpleTextHighlightQuery:
|
class TestParseSimpleTextHighlightQuery:
|
||||||
"""parse_simple_text_highlight_query must not raise on natural-language queries."""
|
"""parse_simple_text_highlight_query must not raise on natural-language queries."""
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def query_index(self) -> tantivy.Index:
|
|
||||||
schema = build_schema()
|
|
||||||
idx = tantivy.Index(schema, path=None)
|
|
||||||
register_tokenizers(idx, "")
|
|
||||||
return idx
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"raw_query",
|
"raw_query",
|
||||||
[
|
[
|
||||||
@@ -783,16 +187,25 @@ class TestParseSimpleTextHighlightQuery:
|
|||||||
tantivy.Query,
|
tantivy.Query,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_query_returns_empty_query(self, query_index: tantivy.Index) -> None:
|
def test_a_real_token_matches_the_corpus(
|
||||||
result = parse_simple_text_highlight_query(query_index, "")
|
|
||||||
assert isinstance(result, tantivy.Query)
|
|
||||||
|
|
||||||
def test_all_operators_returns_empty_query(
|
|
||||||
self,
|
self,
|
||||||
query_index: tantivy.Index,
|
populated_index: tantivy.Index,
|
||||||
) -> None:
|
) -> None:
|
||||||
result = parse_simple_text_highlight_query(query_index, "- +")
|
"""Without this, an empty corpus would make the two assertions below
|
||||||
assert isinstance(result, tantivy.Query)
|
pass for a query that matches every document."""
|
||||||
|
assert _highlight_hit_count(populated_index, "needle") == 1
|
||||||
|
|
||||||
|
def test_empty_query_matches_no_document(
|
||||||
|
self,
|
||||||
|
populated_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
assert _highlight_hit_count(populated_index, "") == 0
|
||||||
|
|
||||||
|
def test_all_operators_query_matches_no_document(
|
||||||
|
self,
|
||||||
|
populated_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
assert _highlight_hit_count(populated_index, "- +") == 0
|
||||||
|
|
||||||
|
|
||||||
class TestPermissionFilter:
|
class TestPermissionFilter:
|
||||||
@@ -884,3 +297,52 @@ class TestPermissionFilter:
|
|||||||
user = django_user_model(pk=20)
|
user = django_user_model(pk=20)
|
||||||
perm = build_permission_filter(perm_index.schema, user)
|
perm = build_permission_filter(perm_index.schema, user)
|
||||||
assert perm_index.searcher().search(perm, limit=10).count == 1 # only unowned
|
assert perm_index.searcher().search(perm, limit=10).count == 1 # only unowned
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchQueryErrors:
|
||||||
|
def test_invalid_date_query_is_a_search_query_error(self) -> None:
|
||||||
|
err = InvalidDateQuery("created", "notadate")
|
||||||
|
assert isinstance(err, SearchQueryError)
|
||||||
|
assert err.field == "created"
|
||||||
|
assert err.value == "notadate"
|
||||||
|
assert "created" in str(err)
|
||||||
|
assert "notadate" in str(err)
|
||||||
|
|
||||||
|
def test_invalid_number_query_is_a_search_query_error(self) -> None:
|
||||||
|
err = InvalidNumberQuery("asn", "notanumber")
|
||||||
|
assert isinstance(err, SearchQueryError)
|
||||||
|
assert err.field == "asn"
|
||||||
|
assert err.value == "notanumber"
|
||||||
|
assert "asn" in str(err)
|
||||||
|
assert "notanumber" in str(err)
|
||||||
|
|
||||||
|
def test_multiple_search_query_errors_aggregates(self) -> None:
|
||||||
|
sub_errors = [
|
||||||
|
InvalidDateQuery("created", "notadate"),
|
||||||
|
InvalidNumberQuery("asn", "notanumber"),
|
||||||
|
]
|
||||||
|
err = MultipleSearchQueryErrors(sub_errors)
|
||||||
|
assert isinstance(err, SearchQueryError)
|
||||||
|
assert err.errors == tuple(sub_errors)
|
||||||
|
assert "created" in str(err)
|
||||||
|
assert "asn" in str(err)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmitErrorContract:
|
||||||
|
"""A QueryError from emit() surfaces as a SearchQueryError (HTTP 400).
|
||||||
|
|
||||||
|
The Cause-based routing table itself is covered in test_error_routing.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_exists_requires_fast_gets_the_user_facing_rewrite(
|
||||||
|
self,
|
||||||
|
query_index: tantivy.Index,
|
||||||
|
) -> None:
|
||||||
|
# whoosh-compat's own message advises a host-side fast=True config
|
||||||
|
# change the user can't act on, so this checks OUR wording, not
|
||||||
|
# whoosh-compat's (that's its own test suite's job now).
|
||||||
|
with pytest.raises(SearchQueryError) as exc_info:
|
||||||
|
parse_user_query(query_index, "notes.user:*", UTC)
|
||||||
|
assert str(exc_info.value) == (
|
||||||
|
"Existence searches (field:*) are not supported for field 'notes.user'."
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Negation must survive the blended query.
|
||||||
|
|
||||||
|
parse_user_query ORs an exact clause with optional fuzzy and CJK clauses.
|
||||||
|
Each of those is built from positive terms only, so unless the query's
|
||||||
|
exclusions are applied to the blend as a whole, a document the exact
|
||||||
|
clause excluded is re-admitted by whichever other clause is enabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from documents.search._backend import TantivyBackend
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
|
||||||
|
def _matched_ids(backend: TantivyBackend, query: str) -> set[int]:
|
||||||
|
return set(backend.search_ids(query, user=None))
|
||||||
|
|
||||||
|
|
||||||
|
def _index(backend: TantivyBackend, **kwargs: object) -> Document:
|
||||||
|
doc = Document.objects.create(**kwargs)
|
||||||
|
backend.add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fuzzy_enabled(settings: SettingsWrapper) -> None:
|
||||||
|
"""Enable the fuzzy blend clause. The threshold doubles as a minimum
|
||||||
|
score filter, so it is set to 0.0: every hit passes and the test sees
|
||||||
|
the clause's matching behaviour, not the filter's."""
|
||||||
|
settings.ADVANCED_FUZZY_SEARCH_THRESHOLD = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegationConstrainsEveryClause:
|
||||||
|
@pytest.mark.usefixtures("fuzzy_enabled")
|
||||||
|
def test_fuzzy_clause_does_not_readmit_an_excluded_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
secret = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice A",
|
||||||
|
content="invoice total secret",
|
||||||
|
checksum="neg-fuzzy-1",
|
||||||
|
)
|
||||||
|
public = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice B",
|
||||||
|
content="invoice total public",
|
||||||
|
checksum="neg-fuzzy-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "invoice") == {secret.pk, public.pk}
|
||||||
|
assert _matched_ids(backend, "invoice NOT secret") == {public.pk}
|
||||||
|
|
||||||
|
def test_cjk_clause_does_not_readmit_an_excluded_document(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""The CJK clause legitimately carries 東京 here, so rebuilding it
|
||||||
|
from the AST cannot help: only applying the exclusion above the
|
||||||
|
blend keeps the secret document out."""
|
||||||
|
secret = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo A",
|
||||||
|
content="東京都の秘密です secret",
|
||||||
|
checksum="neg-cjk-1",
|
||||||
|
)
|
||||||
|
public = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo B",
|
||||||
|
content="東京都の報告書です public",
|
||||||
|
checksum="neg-cjk-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "東京") == {secret.pk, public.pk}
|
||||||
|
assert _matched_ids(backend, "東京 NOT secret") == {public.pk}
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("fuzzy_enabled")
|
||||||
|
def test_disjunctive_negation_still_admits_the_other_branch(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""'invoice OR NOT secret' excludes nothing on its own: a document
|
||||||
|
matching the left branch stays in even though it contains secret."""
|
||||||
|
secret_invoice = _index(
|
||||||
|
backend,
|
||||||
|
title="Invoice A",
|
||||||
|
content="invoice total secret",
|
||||||
|
checksum="neg-or-1",
|
||||||
|
)
|
||||||
|
unrelated = _index(
|
||||||
|
backend,
|
||||||
|
title="Recipe",
|
||||||
|
content="flour and water",
|
||||||
|
checksum="neg-or-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "invoice OR NOT secret") == {
|
||||||
|
secret_invoice.pk,
|
||||||
|
unrelated.pk,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_a_negation_under_or_does_not_constrain_the_cjk_clause(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
"""The limit of the hoist, pinned deliberately.
|
||||||
|
|
||||||
|
An exclusion that is one branch's own condition cannot be restated
|
||||||
|
above the blend without dropping documents the other branch
|
||||||
|
matches, so it is left where it is and the CJK clause stays
|
||||||
|
unconstrained by it. That shows through here in a way it does not
|
||||||
|
for latin text: the exact clause cannot match a CJK run at all, so
|
||||||
|
the CJK clause is the only thing matching the tokyo documents, and
|
||||||
|
the secret one comes with it.
|
||||||
|
"""
|
||||||
|
secret = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo A",
|
||||||
|
content="東京都の秘密です secret",
|
||||||
|
checksum="neg-or-cjk-1",
|
||||||
|
)
|
||||||
|
public = _index(
|
||||||
|
backend,
|
||||||
|
title="Tokyo B",
|
||||||
|
content="東京都の報告書です public",
|
||||||
|
checksum="neg-or-cjk-2",
|
||||||
|
)
|
||||||
|
bill = _index(
|
||||||
|
backend,
|
||||||
|
title="Bill",
|
||||||
|
content="bill payment received",
|
||||||
|
checksum="neg-or-cjk-3",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _matched_ids(backend, "(東京 AND NOT secret) OR bill") == {
|
||||||
|
bill.pk,
|
||||||
|
public.pk,
|
||||||
|
secret.pk,
|
||||||
|
}
|
||||||
|
# The same exclusion in conjunctive position is hoisted, and does
|
||||||
|
# constrain the CJK clause.
|
||||||
|
assert _matched_ids(backend, "東京 AND NOT secret") == {public.pk}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from whoosh_compat import FieldKind
|
||||||
|
from whoosh_compat import FieldRegistry
|
||||||
|
from whoosh_compat.fields import ResolvedField
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
|
from documents.search._registry import get_field_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def registry() -> FieldRegistry:
|
||||||
|
return get_field_registry(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(registry: FieldRegistry, name: str) -> ResolvedField:
|
||||||
|
ref = registry.make_ref(name)
|
||||||
|
assert ref is not None, f"{name} is not a valid field ref"
|
||||||
|
resolved = registry.resolve(ref)
|
||||||
|
assert resolved is not None, f"{name} did not resolve"
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _distinct_forms(result: str | Sequence[str]) -> tuple[str, ...]:
|
||||||
|
"""The forms a term may match, in order, the way whoosh-compat's emitter
|
||||||
|
reads a pattern_normalizer's answer: a bare str is one form, a sequence is
|
||||||
|
several, deduplicated."""
|
||||||
|
if isinstance(result, str):
|
||||||
|
return (result,)
|
||||||
|
return tuple(dict.fromkeys(result))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldRegistry:
|
||||||
|
def test_internal_id_fields_are_not_registered(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
for name in (
|
||||||
|
"tag_id",
|
||||||
|
"owner_id",
|
||||||
|
"viewer_id",
|
||||||
|
"correspondent_id",
|
||||||
|
"document_type_id",
|
||||||
|
"storage_path_id",
|
||||||
|
"viewer_group_id",
|
||||||
|
):
|
||||||
|
assert name not in registry
|
||||||
|
|
||||||
|
def test_no_queryable_field_name_ends_in_id(self) -> None:
|
||||||
|
# The list above names the seven that were dropped; this catches the
|
||||||
|
# eighth. Internal *_id columns are written for permission filtering
|
||||||
|
# and joins, and whoosh only exposed them as query fields by accident,
|
||||||
|
# so a new one reaching the query surface is a leak rather than a
|
||||||
|
# feature. Checked against PUBLIC_FIELDS rather than the registry so
|
||||||
|
# an internal field is caught where it is declared.
|
||||||
|
leaked = [f.name for f in PUBLIC_FIELDS if f.name.endswith("_id")]
|
||||||
|
assert not leaked, f"internal id fields reached the query surface: {leaked}"
|
||||||
|
|
||||||
|
def test_type_alias_resolves_to_document_type(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
assert _resolve(registry, "type").spec.name == "document_type"
|
||||||
|
|
||||||
|
def test_path_alias_resolves_to_storage_path(self, registry: FieldRegistry) -> None:
|
||||||
|
assert _resolve(registry, "path").spec.name == "storage_path"
|
||||||
|
|
||||||
|
def test_notes_json_subpaths_resolve(self, registry: FieldRegistry) -> None:
|
||||||
|
resolved = _resolve(registry, "notes.user")
|
||||||
|
assert resolved.spec.name == "notes"
|
||||||
|
assert resolved.json_path == "user"
|
||||||
|
assert resolved.is_subpath is True
|
||||||
|
|
||||||
|
def test_custom_fields_json_subpaths_resolve(self, registry: FieldRegistry) -> None:
|
||||||
|
for raw in ("custom_fields.name", "custom_fields.value"):
|
||||||
|
_resolve(registry, raw)
|
||||||
|
|
||||||
|
def test_unregistered_json_subpath_does_not_resolve(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
# An unregistered subpath is not even a valid FieldRef: make_ref
|
||||||
|
# returns None for a dotted name whose subpath isn't registered
|
||||||
|
# (it doesn't produce a ref for resolve() to then reject).
|
||||||
|
assert registry.make_ref("notes.bogus") is None
|
||||||
|
|
||||||
|
def test_tag_is_comma_values(self, registry: FieldRegistry) -> None:
|
||||||
|
assert _resolve(registry, "tag").spec.comma_values is True
|
||||||
|
|
||||||
|
def test_correspondent_is_not_comma_values(self, registry: FieldRegistry) -> None:
|
||||||
|
# "tag" is the only field that opts in. This is only observable here:
|
||||||
|
# end to end the two readings of "correspondent:foo,bar" agree,
|
||||||
|
# because the analyzer splits the literal value on the comma anyway,
|
||||||
|
# so a result-level test cannot tell a value list from literal text.
|
||||||
|
assert _resolve(registry, "correspondent").spec.comma_values is False
|
||||||
|
|
||||||
|
def test_created_is_date_kind(self, registry: FieldRegistry) -> None:
|
||||||
|
resolved = _resolve(registry, "created")
|
||||||
|
assert resolved.spec.kind is FieldKind.DATE
|
||||||
|
assert resolved.spec.date_only is True
|
||||||
|
|
||||||
|
def test_analyzer_lowercases_and_ascii_folds(self, registry: FieldRegistry) -> None:
|
||||||
|
# title uses the paperless_text analyzer: simple -> remove_long ->
|
||||||
|
# lowercase -> ascii_fold [-> stemmer]. With no language configured
|
||||||
|
# (None), no stemmer runs, so "Café" folds to the single token "cafe".
|
||||||
|
resolved = _resolve(registry, "title")
|
||||||
|
assert resolved.spec.analyzer is not None
|
||||||
|
assert resolved.spec.analyzer("Café") == ["cafe"]
|
||||||
|
|
||||||
|
def test_checksum_analyzer_is_identity_single_token(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
# checksum uses the raw tokenizer at index time (no splitting).
|
||||||
|
resolved = _resolve(registry, "checksum")
|
||||||
|
assert resolved.spec.analyzer is not None
|
||||||
|
assert resolved.spec.analyzer("ABC-123") == ["ABC-123"]
|
||||||
|
|
||||||
|
def test_pattern_normalizer_follows_the_registry_language(
|
||||||
|
self,
|
||||||
|
registry: FieldRegistry,
|
||||||
|
) -> None:
|
||||||
|
# Index terms are stemmed, so patterns offer their stem too, using the
|
||||||
|
# registry's own language: "Running" has to reach the indexed "run".
|
||||||
|
# Without a language the index holds surface forms, so there is no
|
||||||
|
# second form and the run is only case/accent-folded.
|
||||||
|
resolved = _resolve(registry, "title")
|
||||||
|
assert resolved.spec.pattern_normalizer is not None
|
||||||
|
assert _distinct_forms(resolved.spec.pattern_normalizer("Running")) == (
|
||||||
|
"running",
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_en = _resolve(get_field_registry("en"), "title")
|
||||||
|
assert resolved_en.spec.pattern_normalizer is not None
|
||||||
|
assert _distinct_forms(resolved_en.spec.pattern_normalizer("Running")) == (
|
||||||
|
"running",
|
||||||
|
"run",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registry_is_cached_per_language(self) -> None:
|
||||||
|
a = get_field_registry("en")
|
||||||
|
b = get_field_registry("en")
|
||||||
|
assert a is b
|
||||||
|
|
||||||
|
def test_registry_rebuilds_on_language_change(self) -> None:
|
||||||
|
a = get_field_registry("en")
|
||||||
|
b = get_field_registry("de")
|
||||||
|
assert a is not b
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
|
||||||
|
from documents.search._fields import PUBLIC_FIELDS
|
||||||
from documents.search._schema import SCHEMA_VERSION
|
from documents.search._schema import SCHEMA_VERSION
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._schema import field_descriptors
|
||||||
from documents.search._schema import needs_rebuild
|
from documents.search._schema import needs_rebuild
|
||||||
|
from documents.search._schema import schema_fingerprint
|
||||||
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -29,7 +37,13 @@ class TestNeedsRebuild:
|
|||||||
) -> None:
|
) -> None:
|
||||||
settings.SEARCH_LANGUAGE = "en"
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
(index_dir / ".index_settings.json").write_text(
|
(index_dir / ".index_settings.json").write_text(
|
||||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"language": "en",
|
||||||
|
"schema_fingerprint": schema_fingerprint(),
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is False
|
assert needs_rebuild(index_dir) is False
|
||||||
|
|
||||||
@@ -76,3 +90,72 @@ class TestNeedsRebuild:
|
|||||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
||||||
)
|
)
|
||||||
assert needs_rebuild(index_dir) is True
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_fields(schema: tantivy.Schema) -> dict[str, dict]:
|
||||||
|
"""{name: field-state} for every field declared on a tantivy Schema.
|
||||||
|
|
||||||
|
tantivy-py 0.26 exposes no public introspection API on Schema (no
|
||||||
|
__iter__, get_field, to_json, etc.) -- __reduce__() (used internally for
|
||||||
|
pickling) is the only way to recover the field list, so we lean on it
|
||||||
|
here for test assertions only.
|
||||||
|
"""
|
||||||
|
state = schema.__reduce__()[1][0]
|
||||||
|
return {field["name"]: field for field in state["inner"]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaMatchesPublicFields:
|
||||||
|
def test_every_public_field_is_in_the_schema(self) -> None:
|
||||||
|
schema = build_schema()
|
||||||
|
schema_field_names = set(_schema_fields(schema))
|
||||||
|
for field in PUBLIC_FIELDS:
|
||||||
|
assert field.name in schema_field_names, (
|
||||||
|
f"{field.name} is in PUBLIC_FIELDS but missing from build_schema()"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_asn_page_count_num_notes_are_fast_unsigned_fields(self) -> None:
|
||||||
|
# Spot-check kind-derived construction for the U64 fields.
|
||||||
|
schema = build_schema()
|
||||||
|
doc = tantivy.Document()
|
||||||
|
doc.add_unsigned("id", 1)
|
||||||
|
doc.add_text("checksum", "x")
|
||||||
|
doc.add_unsigned("asn", 42)
|
||||||
|
doc.add_unsigned("page_count", 3)
|
||||||
|
doc.add_unsigned("num_notes", 0)
|
||||||
|
doc.add_date("created", datetime(2020, 1, 1, tzinfo=UTC))
|
||||||
|
doc.add_date("modified", datetime(2020, 1, 1, tzinfo=UTC))
|
||||||
|
doc.add_date("added", datetime(2020, 1, 1, tzinfo=UTC))
|
||||||
|
index = tantivy.Index(schema)
|
||||||
|
register_tokenizers(index, None)
|
||||||
|
writer = index.writer()
|
||||||
|
writer.add_document(doc)
|
||||||
|
writer.commit()
|
||||||
|
index.reload()
|
||||||
|
searcher = index.searcher()
|
||||||
|
results = searcher.search(tantivy.Query.term_query(schema, "asn", 42), limit=1)
|
||||||
|
assert len(results.hits) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestFastFlagAgreement:
|
||||||
|
def test_every_public_field_fast_flag_matches_the_built_schema(self) -> None:
|
||||||
|
# whoosh-compat's registry trusts PUBLIC_FIELDS' fast flag when resolving
|
||||||
|
# field:* existence checks (its FAST_FIELD strategy); a fast=True
|
||||||
|
# entry whose actual tantivy column is not fast would make those
|
||||||
|
# searches silently match nothing at search time. Only the U64 and
|
||||||
|
# DATE descriptors can carry the flag today, so this
|
||||||
|
# pins the agreement for EVERY kind: a future fast=True
|
||||||
|
# TEXT/KEYWORD/JSON entry the builder silently ignores fails here
|
||||||
|
# instead of at a user's query.
|
||||||
|
#
|
||||||
|
# field_descriptors() (not tantivy-py's __reduce__() pickling
|
||||||
|
# internals) is used as the probe here: it is exactly the input
|
||||||
|
# build_schema()'s SchemaBuilder consumes for the `fast` kwarg on
|
||||||
|
# every field kind, so it pins the same agreement without depending
|
||||||
|
# on a private pickled representation surviving a tantivy-py
|
||||||
|
# upgrade.
|
||||||
|
descriptor_fast = {d.name: d.fast for d in field_descriptors()}
|
||||||
|
for public_field in PUBLIC_FIELDS:
|
||||||
|
assert descriptor_fast[public_field.name] == public_field.fast, (
|
||||||
|
f"{public_field.name}: PUBLIC_FIELDS says fast={public_field.fast} but"
|
||||||
|
f" field_descriptors() says fast={descriptor_fast[public_field.name]}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"""The schema fingerprint stamped into .index_settings.json.
|
||||||
|
|
||||||
|
tantivy compares schemas by *ordered* field list, and `tantivy.Index(schema,
|
||||||
|
path=...)` (what every write path does) raises on any difference. SCHEMA_VERSION
|
||||||
|
is the manual guard against that, but build_schema() is edited for *parser*
|
||||||
|
reasons - adding an alias, flipping fast=True, adding a subpath - by people not
|
||||||
|
thinking about the on-disk index, and forgetting the bump is exactly how this
|
||||||
|
branch's bug happened.
|
||||||
|
|
||||||
|
The fingerprint is the automatic guard: it hashes the field descriptor list that
|
||||||
|
build_schema() itself iterates, so any change to a field's name, kind, options
|
||||||
|
or *position* forces a rebuild on its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
|
||||||
|
from documents.search import _schema
|
||||||
|
from documents.search._schema import SCHEMA_VERSION
|
||||||
|
from documents.search._schema import FieldDescriptor
|
||||||
|
from documents.search._schema import _write_sentinels
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._schema import field_descriptors
|
||||||
|
from documents.search._schema import needs_rebuild
|
||||||
|
from documents.search._schema import schema_fingerprint
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.search
|
||||||
|
|
||||||
|
# The on-disk field layout of a v2 index, pinned as data. Any edit here is an
|
||||||
|
# index-format change: it must come with a rebuild, which the fingerprint now
|
||||||
|
# forces automatically. Reproduced from build_schema()'s output as it stood
|
||||||
|
# before the descriptor refactor, so it also pins that the refactor changed
|
||||||
|
# nothing.
|
||||||
|
PINNED_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
||||||
|
FieldDescriptor("id", "u64", stored=True, indexed=True, fast=True, tokenizer=None),
|
||||||
|
FieldDescriptor(
|
||||||
|
"title",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"content",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"correspondent",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"document_type",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"storage_path",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"original_filename",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"tag",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"checksum",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="raw",
|
||||||
|
),
|
||||||
|
FieldDescriptor("asn", "u64", stored=True, indexed=True, fast=True, tokenizer=None),
|
||||||
|
FieldDescriptor(
|
||||||
|
"page_count",
|
||||||
|
"u64",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"num_notes",
|
||||||
|
"u64",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"created",
|
||||||
|
"date",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"modified",
|
||||||
|
"date",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"added",
|
||||||
|
"date",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"notes",
|
||||||
|
"json",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"notes_text",
|
||||||
|
"text",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"custom_fields",
|
||||||
|
"json",
|
||||||
|
stored=True,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="paperless_text",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"title_sort",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer="simple_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"correspondent_sort",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer="simple_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"type_sort",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer="simple_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_content",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_title",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_correspondent",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_document_type",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"bigram_tag",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="bigram_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"simple_title",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="simple_search_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"simple_content",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="simple_search_analyzer",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"autocomplete_word",
|
||||||
|
"text",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=False,
|
||||||
|
tokenizer="raw",
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"owner_id",
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"viewer_id",
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
FieldDescriptor(
|
||||||
|
"viewer_group_id",
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_fields(schema: tantivy.Schema) -> list[dict]:
|
||||||
|
"""The tantivy-level field list, in declaration order.
|
||||||
|
|
||||||
|
tantivy-py 0.26 exposes no public introspection API on Schema, so
|
||||||
|
__reduce__() (its pickling hook) is the only way to recover the field list.
|
||||||
|
It is used here, in a test, precisely because it is the representation the
|
||||||
|
persisted fingerprint must NOT depend on.
|
||||||
|
"""
|
||||||
|
return schema.__reduce__()[1][0]["inner"]
|
||||||
|
|
||||||
|
|
||||||
|
def _sentinels(index_dir: Path, **overrides: object) -> None:
|
||||||
|
data = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"language": None,
|
||||||
|
"schema_fingerprint": schema_fingerprint(),
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
(index_dir / ".index_settings.json").write_text(json.dumps(data))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescriptorsDescribeTheBuiltSchema:
|
||||||
|
def test_descriptors_match_the_pinned_field_layout(self) -> None:
|
||||||
|
assert tuple(field_descriptors()) == PINNED_DESCRIPTORS
|
||||||
|
|
||||||
|
def test_built_schema_matches_the_descriptors(self) -> None:
|
||||||
|
"""The descriptors are not a parallel description - they are the input.
|
||||||
|
|
||||||
|
Reading the built schema back proves the loop honours every option, so
|
||||||
|
a descriptor edit cannot claim a shape the SchemaBuilder did not build.
|
||||||
|
"""
|
||||||
|
kinds = {"text": "text", "json": "json_object", "u64": "u64", "date": "date"}
|
||||||
|
built = [
|
||||||
|
(
|
||||||
|
field["name"],
|
||||||
|
field["type"],
|
||||||
|
field["options"]["stored"],
|
||||||
|
bool(field["options"].get("fast")),
|
||||||
|
(field["options"].get("indexing") or {}).get("tokenizer"),
|
||||||
|
)
|
||||||
|
for field in _schema_fields(build_schema())
|
||||||
|
]
|
||||||
|
expected = [
|
||||||
|
(
|
||||||
|
descriptor.name,
|
||||||
|
kinds[descriptor.kind],
|
||||||
|
descriptor.stored,
|
||||||
|
descriptor.fast,
|
||||||
|
descriptor.tokenizer,
|
||||||
|
)
|
||||||
|
for descriptor in field_descriptors()
|
||||||
|
]
|
||||||
|
assert built == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestFingerprintSensitivity:
|
||||||
|
def test_a_field_option_change_moves_the_fingerprint(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
before = schema_fingerprint()
|
||||||
|
changed = field_descriptors()
|
||||||
|
changed[1] = changed[1]._replace(fast=True)
|
||||||
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: changed)
|
||||||
|
|
||||||
|
assert schema_fingerprint() != before
|
||||||
|
|
||||||
|
def test_reordering_alone_moves_the_fingerprint(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The original bug: same fields, different declaration order.
|
||||||
|
|
||||||
|
A set- or dict-based fingerprint would be blind to this, and tantivy
|
||||||
|
would reject every write against the existing index.
|
||||||
|
"""
|
||||||
|
before = schema_fingerprint()
|
||||||
|
swapped = field_descriptors()
|
||||||
|
swapped[1], swapped[2] = swapped[2], swapped[1]
|
||||||
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: swapped)
|
||||||
|
|
||||||
|
assert schema_fingerprint() != before
|
||||||
|
|
||||||
|
def test_repeated_calls_agree(self) -> None:
|
||||||
|
assert schema_fingerprint() == schema_fingerprint()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFingerprintIsIndependentOfTantivy:
|
||||||
|
def test_a_tantivy_option_key_addition_would_not_move_it(self) -> None:
|
||||||
|
"""A tantivy-py upgrade must not force a global reindex.
|
||||||
|
|
||||||
|
Hashing schema.__reduce__() would do exactly that: the simulated new
|
||||||
|
option key below changes that payload for every user with no schema
|
||||||
|
change at all.
|
||||||
|
"""
|
||||||
|
fields = _schema_fields(build_schema())
|
||||||
|
upgraded = [
|
||||||
|
{**field, "options": {**field["options"], "coerce": True}}
|
||||||
|
for field in fields
|
||||||
|
]
|
||||||
|
assert _hash(upgraded) != _hash(fields)
|
||||||
|
assert schema_fingerprint() == _fingerprint_of(field_descriptors())
|
||||||
|
|
||||||
|
def test_fingerprint_never_touches_the_schema_builder(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
before = schema_fingerprint()
|
||||||
|
|
||||||
|
class _RemovedSchemaBuilder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
raise AssertionError("tantivy.SchemaBuilder was consulted")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tantivy, "SchemaBuilder", _RemovedSchemaBuilder)
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
build_schema()
|
||||||
|
|
||||||
|
assert schema_fingerprint() == before
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(payload: object) -> str:
|
||||||
|
return hashlib.blake2b(json.dumps(payload).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint_of(descriptors: list[FieldDescriptor]) -> str:
|
||||||
|
return _hash([list(descriptor) for descriptor in descriptors])
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsRebuildOnFingerprint:
|
||||||
|
def test_matching_fingerprint_does_not_rebuild(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = None
|
||||||
|
_sentinels(index_dir)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is False
|
||||||
|
|
||||||
|
def test_stale_fingerprint_rebuilds_despite_a_matching_version(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The failure this task exists to prevent: schema edited, version not
|
||||||
|
bumped. Without the fingerprint check, `reindex --if-needed` reports the
|
||||||
|
index up to date and every write then raises."""
|
||||||
|
settings.SEARCH_LANGUAGE = None
|
||||||
|
_sentinels(index_dir)
|
||||||
|
extended = [
|
||||||
|
*field_descriptors(),
|
||||||
|
FieldDescriptor(
|
||||||
|
"new_field",
|
||||||
|
"u64",
|
||||||
|
stored=False,
|
||||||
|
indexed=True,
|
||||||
|
fast=True,
|
||||||
|
tokenizer=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: extended)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_reordered_schema_rebuilds(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = None
|
||||||
|
_sentinels(index_dir)
|
||||||
|
reordered = field_descriptors()
|
||||||
|
reordered[1], reordered[2] = reordered[2], reordered[1]
|
||||||
|
monkeypatch.setattr(_schema, "field_descriptors", lambda: reordered)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_missing_fingerprint_rebuilds(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
"""No seeding: an index whose schema shape nobody recorded is rebuilt
|
||||||
|
rather than trusted."""
|
||||||
|
settings.SEARCH_LANGUAGE = None
|
||||||
|
(index_dir / ".index_settings.json").write_text(
|
||||||
|
json.dumps({"schema_version": SCHEMA_VERSION, "language": None}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is True
|
||||||
|
|
||||||
|
def test_written_sentinels_satisfy_the_check(
|
||||||
|
self,
|
||||||
|
index_dir: Path,
|
||||||
|
settings: SettingsWrapper,
|
||||||
|
) -> None:
|
||||||
|
settings.SEARCH_LANGUAGE = "en"
|
||||||
|
_write_sentinels(index_dir)
|
||||||
|
|
||||||
|
assert needs_rebuild(index_dir) is False
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""SCHEMA_VERSION must change whenever build_schema()'s field list or order does.
|
||||||
|
|
||||||
|
tantivy compares schemas by *ordered* field list. ``Index.open()`` loads the
|
||||||
|
schema from the index's own ``meta.json``, so reads against an index built by an
|
||||||
|
older release keep working after a field reorder. Writes do not:
|
||||||
|
``WriteBatch.__enter__`` calls ``tantivy.Index(build_schema(), path=...)``, an
|
||||||
|
open-or-create that raises ``ValueError`` on any schema difference. Nothing
|
||||||
|
catches that ValueError, so consumption, index_document and bulk edit all
|
||||||
|
hard-fail while ``/api/status/`` still reports the index healthy.
|
||||||
|
|
||||||
|
The only thing that saves such an install is ``needs_rebuild()`` noticing the
|
||||||
|
version stamped in ``.index_settings.json`` is stale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tantivy
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
from documents.search._schema import build_schema
|
||||||
|
from documents.search._schema import needs_rebuild
|
||||||
|
from documents.search._schema import open_or_rebuild_index
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.search]
|
||||||
|
|
||||||
|
RELEASED_V1_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _build_released_v1_schema() -> tantivy.Schema:
|
||||||
|
"""Frozen copy of build_schema() as shipped in v3.0.x (schema version 1).
|
||||||
|
|
||||||
|
Deliberately duplicated rather than imported: it must keep describing the
|
||||||
|
on-disk layout of already-deployed indexes even as build_schema() evolves.
|
||||||
|
"""
|
||||||
|
sb = tantivy.SchemaBuilder()
|
||||||
|
|
||||||
|
sb.add_unsigned_field("id", stored=True, indexed=True, fast=True)
|
||||||
|
sb.add_text_field("checksum", stored=True, tokenizer_name="raw")
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"title",
|
||||||
|
"correspondent",
|
||||||
|
"document_type",
|
||||||
|
"storage_path",
|
||||||
|
"original_filename",
|
||||||
|
"content",
|
||||||
|
):
|
||||||
|
sb.add_text_field(field, stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
for field in ("title_sort", "correspondent_sort", "type_sort"):
|
||||||
|
sb.add_text_field(
|
||||||
|
field,
|
||||||
|
stored=False,
|
||||||
|
tokenizer_name="simple_analyzer",
|
||||||
|
fast=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"bigram_content",
|
||||||
|
"bigram_title",
|
||||||
|
"bigram_correspondent",
|
||||||
|
"bigram_document_type",
|
||||||
|
"bigram_tag",
|
||||||
|
):
|
||||||
|
sb.add_text_field(field, stored=False, tokenizer_name="bigram_analyzer")
|
||||||
|
|
||||||
|
for field in ("simple_title", "simple_content"):
|
||||||
|
sb.add_text_field(field, stored=False, tokenizer_name="simple_search_analyzer")
|
||||||
|
|
||||||
|
sb.add_text_field("autocomplete_word", stored=False, tokenizer_name="raw")
|
||||||
|
sb.add_text_field("tag", stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
sb.add_json_field("notes", stored=True, tokenizer_name="paperless_text")
|
||||||
|
sb.add_text_field("notes_text", stored=True, tokenizer_name="paperless_text")
|
||||||
|
sb.add_json_field("custom_fields", stored=True, tokenizer_name="paperless_text")
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"correspondent_id",
|
||||||
|
"document_type_id",
|
||||||
|
"storage_path_id",
|
||||||
|
"tag_id",
|
||||||
|
"owner_id",
|
||||||
|
"viewer_id",
|
||||||
|
"viewer_group_id",
|
||||||
|
):
|
||||||
|
sb.add_unsigned_field(field, stored=False, indexed=True, fast=True)
|
||||||
|
|
||||||
|
for field in ("created", "modified", "added"):
|
||||||
|
sb.add_date_field(field, stored=True, indexed=True, fast=True)
|
||||||
|
|
||||||
|
for field in ("asn", "page_count", "num_notes"):
|
||||||
|
sb.add_unsigned_field(field, stored=True, indexed=True, fast=True)
|
||||||
|
|
||||||
|
return sb.build()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def released_v1_index(tmp_path: Path) -> Path:
|
||||||
|
"""An index directory as a v3.0.x install would leave it on disk."""
|
||||||
|
index_dir = tmp_path / "index"
|
||||||
|
index_dir.mkdir()
|
||||||
|
tantivy.Index(_build_released_v1_schema(), path=str(index_dir))
|
||||||
|
(index_dir / ".index_settings.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": RELEASED_V1_SCHEMA_VERSION,
|
||||||
|
"language": django_settings.SEARCH_LANGUAGE,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return index_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpgradeFromReleasedV1Index:
|
||||||
|
def test_released_v1_index_is_flagged_for_rebuild(
|
||||||
|
self,
|
||||||
|
released_v1_index: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The current schema differs from v1's, so the sentinel must be stale.
|
||||||
|
|
||||||
|
If this fails, `document_index reindex --if-needed` prints "Search index
|
||||||
|
is up to date" and skips, leaving the mismatched index in place.
|
||||||
|
"""
|
||||||
|
assert needs_rebuild(released_v1_index) is True
|
||||||
|
|
||||||
|
def test_v1_index_rejects_writes_against_the_current_schema(
|
||||||
|
self,
|
||||||
|
released_v1_index: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The failure mode the version bump exists to prevent.
|
||||||
|
|
||||||
|
This is exactly what WriteBatch.__enter__ does on every index write.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValueError, match="schema does not match"):
|
||||||
|
tantivy.Index(build_schema(), path=str(released_v1_index))
|
||||||
|
|
||||||
|
def test_opening_a_v1_index_leaves_it_writable(
|
||||||
|
self,
|
||||||
|
released_v1_index: Path,
|
||||||
|
) -> None:
|
||||||
|
"""End to end: open_or_rebuild_index must hand back an index that the
|
||||||
|
write path can reopen. Before the version bump, needs_rebuild() returned
|
||||||
|
False here, the stale directory survived untouched, and every subsequent
|
||||||
|
write raised the ValueError above."""
|
||||||
|
open_or_rebuild_index(released_v1_index)
|
||||||
|
|
||||||
|
tantivy.Index(build_schema(), path=str(released_v1_index))
|
||||||
|
|
||||||
|
def test_rebuilt_index_is_not_rebuilt_again(
|
||||||
|
self,
|
||||||
|
released_v1_index: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The rebuild must stamp the version it actually wrote, otherwise every
|
||||||
|
startup wipes and reindexes the whole corpus."""
|
||||||
|
open_or_rebuild_index(released_v1_index)
|
||||||
|
|
||||||
|
assert needs_rebuild(released_v1_index) is False
|
||||||
@@ -7,8 +7,8 @@ import pytest
|
|||||||
import tantivy
|
import tantivy
|
||||||
|
|
||||||
from documents.search._tokenizer import _bigram_analyzer
|
from documents.search._tokenizer import _bigram_analyzer
|
||||||
from documents.search._tokenizer import _paperless_text
|
|
||||||
from documents.search._tokenizer import _simple_search_analyzer
|
from documents.search._tokenizer import _simple_search_analyzer
|
||||||
|
from documents.search._tokenizer import paperless_text_analyzer
|
||||||
from documents.search._tokenizer import register_tokenizers
|
from documents.search._tokenizer import register_tokenizers
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -25,7 +25,7 @@ class TestTokenizers:
|
|||||||
sb.add_text_field("content", stored=True, tokenizer_name="paperless_text")
|
sb.add_text_field("content", stored=True, tokenizer_name="paperless_text")
|
||||||
schema = sb.build()
|
schema = sb.build()
|
||||||
idx = tantivy.Index(schema, path=None)
|
idx = tantivy.Index(schema, path=None)
|
||||||
idx.register_tokenizer("paperless_text", _paperless_text(""))
|
idx.register_tokenizer("paperless_text", paperless_text_analyzer(""))
|
||||||
return idx
|
return idx
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,810 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import time_machine
|
|
||||||
|
|
||||||
from documents.search._dates import _precision_bounds
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
import tantivy
|
|
||||||
from documents.search._query import _FIELD_BOOSTS
|
|
||||||
from documents.search._query import DEFAULT_SEARCH_FIELDS
|
|
||||||
from documents.search._translate import OPEN_HI
|
|
||||||
from documents.search._translate import OPEN_LO
|
|
||||||
from documents.search._translate import Comma
|
|
||||||
from documents.search._translate import FieldRange
|
|
||||||
from documents.search._translate import FieldValue
|
|
||||||
from documents.search._translate import FieldValueList
|
|
||||||
from documents.search._translate import InvalidDateQuery
|
|
||||||
from documents.search._translate import Passthrough
|
|
||||||
from documents.search._translate import resolve_commas
|
|
||||||
from documents.search._translate import scan
|
|
||||||
from documents.search._translate import translate_query
|
|
||||||
from documents.search._translate import translate_range
|
|
||||||
from documents.search._translate import translate_scalar
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestPrecisionBounds:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("digits", "expected"),
|
|
||||||
[
|
|
||||||
("2020", ((2020, 1, 1), (2021, 1, 1))),
|
|
||||||
("202003", ((2020, 3, 1), (2020, 4, 1))),
|
|
||||||
("202012", ((2020, 12, 1), (2021, 1, 1))),
|
|
||||||
("20200115", ((2020, 1, 15), (2020, 1, 16))),
|
|
||||||
("20201231", ((2020, 12, 31), (2021, 1, 1))),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_valid(self, digits, expected):
|
|
||||||
lo, hi = _precision_bounds(digits)
|
|
||||||
assert (lo.year, lo.month, lo.day) == expected[0]
|
|
||||||
assert (hi.year, hi.month, hi.day) == expected[1]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("digits", ["202023", "20200230", "20201301", "20", "abcd"])
|
|
||||||
def test_invalid_returns_none(self, digits):
|
|
||||||
assert _precision_bounds(digits) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestScan:
|
|
||||||
def test_plain_words_are_passthrough(self):
|
|
||||||
assert scan("bank statement") == [Passthrough("bank statement")]
|
|
||||||
|
|
||||||
def test_field_value(self):
|
|
||||||
assert scan("created:2020") == [FieldValue("created", "2020")]
|
|
||||||
|
|
||||||
def test_field_value_in_boolean(self):
|
|
||||||
toks = scan("created:2020 OR foo")
|
|
||||||
assert toks == [
|
|
||||||
FieldValue("created", "2020"),
|
|
||||||
Passthrough(" OR foo"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_field_value_in_parens(self):
|
|
||||||
toks = scan("(created:2020 OR foo)")
|
|
||||||
assert toks == [
|
|
||||||
Passthrough("("),
|
|
||||||
FieldValue("created", "2020"),
|
|
||||||
Passthrough(" OR foo)"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_quoted_value(self):
|
|
||||||
assert scan('correspondent:"A B"') == [FieldValue("correspondent", '"A B"')]
|
|
||||||
|
|
||||||
def test_field_range(self):
|
|
||||||
assert scan("created:[2020 TO 2021]") == [
|
|
||||||
FieldRange("created", "[", "2020", "2021", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("query", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"created:[2020 to]",
|
|
||||||
FieldRange("created", "[", "2020", "", "]"),
|
|
||||||
id="open_upper",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"created:[to 2020]",
|
|
||||||
FieldRange("created", "[", "", "2020", "]"),
|
|
||||||
id="open_lower",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_open_range(self, query, expected):
|
|
||||||
assert scan(query) == [expected]
|
|
||||||
|
|
||||||
def test_comma_inside_range_not_split(self):
|
|
||||||
# No depth-0 comma here; the whole thing is one range token.
|
|
||||||
toks = scan("created:[2020 TO 2021]")
|
|
||||||
assert len(toks) == 1
|
|
||||||
|
|
||||||
# --- Edge-case / regression tests (scan must never raise) ---
|
|
||||||
|
|
||||||
def test_url_is_passthrough(self):
|
|
||||||
# "http" is not a known field; the whole URL must pass through verbatim.
|
|
||||||
assert scan("http://example.com") == [Passthrough("http://example.com")]
|
|
||||||
|
|
||||||
def test_unterminated_quote_is_passthrough(self):
|
|
||||||
# title is a known field but the quoted value has no closing quote;
|
|
||||||
# _consume_value returns None so the whole string falls into passthrough.
|
|
||||||
assert scan('title:"abc') == [Passthrough('title:"abc')]
|
|
||||||
|
|
||||||
def test_unterminated_bracket_is_passthrough(self):
|
|
||||||
# created is a known field but the range bracket is never closed;
|
|
||||||
# _consume_range returns None so the whole string falls into passthrough.
|
|
||||||
assert scan("created:[2020") == [Passthrough("created:[2020")]
|
|
||||||
|
|
||||||
def test_empty_value_at_end_is_passthrough(self):
|
|
||||||
# created is a known field but there is no value after the colon
|
|
||||||
# (_consume_value returns None for start >= n), so passthrough.
|
|
||||||
assert scan("created:") == [Passthrough("created:")]
|
|
||||||
|
|
||||||
def test_value_containing_colon(self):
|
|
||||||
# The bare-word value reader stops at whitespace/paren, not at colon,
|
|
||||||
# so "2020:30" is consumed as a single value token.
|
|
||||||
assert scan("created:2020:30") == [FieldValue("created", "2020:30")]
|
|
||||||
|
|
||||||
def test_comma_followed_by_unconsumable_value_stops(self):
|
|
||||||
# A comma followed by whitespace is neither a value-list continuation nor a
|
|
||||||
# clause separator: the value stops and the comma stays as passthrough.
|
|
||||||
assert scan("tag:foo, bar") == [
|
|
||||||
FieldValue("tag", "foo"),
|
|
||||||
Passthrough(", bar"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_bracket_without_to_is_open_upper_bound(self):
|
|
||||||
# A bracketed value with no TO falls back to (value, "") -> open upper bound.
|
|
||||||
assert scan("created:[2020]") == [
|
|
||||||
FieldRange("created", "[", "2020", "", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_known_field_name_midword_is_passthrough(self):
|
|
||||||
# A known field name embedded mid-word is not a field token (the
|
|
||||||
# word-boundary guard); the whole run stays passthrough.
|
|
||||||
assert scan("xtag:foo") == [Passthrough("xtag:foo")]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestCommaResolution:
|
|
||||||
def test_value_list_multi_value_field(self):
|
|
||||||
toks = resolve_commas(scan("tag:foo,bar"))
|
|
||||||
assert toks == [FieldValueList("tag", ("foo", "bar"))]
|
|
||||||
|
|
||||||
def test_value_list_three(self):
|
|
||||||
toks = resolve_commas(scan("tag_id:1,2,3"))
|
|
||||||
assert toks == [FieldValueList("tag_id", ("1", "2", "3"))]
|
|
||||||
|
|
||||||
def test_text_field_comma_is_literal(self):
|
|
||||||
# correspondent is not multi-value: comma stays inside the value.
|
|
||||||
toks = resolve_commas(scan("correspondent:foo,bar"))
|
|
||||||
assert toks == [FieldValue("correspondent", "foo,bar")]
|
|
||||||
|
|
||||||
def test_clause_separator_before_known_field(self):
|
|
||||||
toks = resolve_commas(scan("tag:foo,type:bar"))
|
|
||||||
assert toks == [FieldValue("tag", "foo"), Comma(), FieldValue("type", "bar")]
|
|
||||||
|
|
||||||
def test_clause_separator_after_range(self):
|
|
||||||
toks = resolve_commas(scan("created:[2020 TO 2021],added:[2022 TO 2023]"))
|
|
||||||
assert toks == [
|
|
||||||
FieldRange("created", "[", "2020", "2021", "]"),
|
|
||||||
Comma(),
|
|
||||||
FieldRange("added", "[", "2022", "2023", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_clause_separator_after_quote(self):
|
|
||||||
toks = resolve_commas(scan('correspondent:"A B",created:[2020 TO 2021]'))
|
|
||||||
assert toks == [
|
|
||||||
FieldValue("correspondent", '"A B"'),
|
|
||||||
Comma(),
|
|
||||||
FieldRange("created", "[", "2020", "2021", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_url_comma_is_literal_passthrough(self):
|
|
||||||
toks = resolve_commas(scan("http://example.com/a,b"))
|
|
||||||
assert toks == [Passthrough("http://example.com/a,b")]
|
|
||||||
|
|
||||||
def test_non_multi_value_comma_is_literal(self):
|
|
||||||
# title is not in MULTI_VALUE_FIELDS: comma stays inside the value.
|
|
||||||
toks = resolve_commas(scan("title:10,20"))
|
|
||||||
assert toks == [FieldValue("title", "10,20")]
|
|
||||||
|
|
||||||
def test_clause_separator_before_known_date_field(self):
|
|
||||||
# The comma between a bare value and a known date field acts as a
|
|
||||||
# clause separator; both sides survive as distinct tokens.
|
|
||||||
toks = resolve_commas(scan("correspondent:foo,created:[2020 TO 2021]"))
|
|
||||||
assert toks == [
|
|
||||||
FieldValue("correspondent", "foo"),
|
|
||||||
Comma(),
|
|
||||||
FieldRange("created", "[", "2020", "2021", "]"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestTranslateScalar:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("field", "value", "expected"),
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"2020",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"202003",
|
|
||||||
"created:[2020-03-01T00:00:00Z TO 2020-04-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"20200115",
|
|
||||||
"created:[2020-01-15T00:00:00Z TO 2020-01-16T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"2020-01-15",
|
|
||||||
"created:[2020-01-15T00:00:00Z TO 2020-01-16T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created",
|
|
||||||
"2020-03",
|
|
||||||
"created:[2020-03-01T00:00:00Z TO 2020-04-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_partial_and_iso_dates(self, field: str, value: str, expected: str) -> None:
|
|
||||||
assert translate_scalar(field, value, UTC) == expected
|
|
||||||
|
|
||||||
def test_invalid_date_raises(self) -> None:
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_scalar("created", "202023", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "202023"
|
|
||||||
|
|
||||||
def test_keyword_delegates(self) -> None:
|
|
||||||
# keyword path produces a half-open range; just assert it is a created range
|
|
||||||
out = translate_scalar("created", "today", UTC)
|
|
||||||
assert out.startswith("created:[") and out.endswith("}")
|
|
||||||
|
|
||||||
def test_14digit_compact_datetime(self) -> None:
|
|
||||||
out = translate_scalar("created", "20240115120000", UTC)
|
|
||||||
assert "20240115120000" not in out
|
|
||||||
assert out.startswith("created:")
|
|
||||||
assert out == "created:[2024-01-15T12:00:00Z TO 2024-01-15T12:00:00Z]"
|
|
||||||
|
|
||||||
def test_14digit_invalid_month_raises(self) -> None:
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_scalar("created", "20231300120000", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "20231300120000"
|
|
||||||
|
|
||||||
def test_unrecognized_value_raises(self) -> None:
|
|
||||||
# A value that is not a keyword, digits, ISO date, or compact timestamp
|
|
||||||
# raises rather than producing invalid Tantivy syntax or silently matching
|
|
||||||
# nothing.
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_scalar("created", "garbage", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "garbage"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestTranslateRange:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("lo", "hi", "expected"),
|
|
||||||
[
|
|
||||||
("2005", "2009", "created:[2005-01-01T00:00:00Z TO 2010-01-01T00:00:00Z}"),
|
|
||||||
(
|
|
||||||
"202001",
|
|
||||||
"202006",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2020-07-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"20200101",
|
|
||||||
"20201231",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"2020-01-01",
|
|
||||||
"2020-12-31",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_absolute_ranges(self, lo, hi, expected):
|
|
||||||
assert translate_range("created", lo, hi, UTC) == expected
|
|
||||||
|
|
||||||
def test_reversed_swaps(self):
|
|
||||||
assert translate_range("created", "2009", "2005", UTC) == (
|
|
||||||
"created:[2005-01-01T00:00:00Z TO 2010-01-01T00:00:00Z}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_open_upper(self):
|
|
||||||
out = translate_range("created", "2020", "", UTC)
|
|
||||||
assert out == f"created:[2020-01-01T00:00:00Z TO {OPEN_HI}]"
|
|
||||||
|
|
||||||
def test_open_lower(self):
|
|
||||||
out = translate_range("created", "", "2020", UTC)
|
|
||||||
assert out == f"created:[{OPEN_LO} TO 2021-01-01T00:00:00Z}}"
|
|
||||||
|
|
||||||
def test_invalid_bound_raises(self):
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_range("created", "202023", "2025", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "202023"
|
|
||||||
|
|
||||||
def test_invalid_high_bound_raises(self):
|
|
||||||
# Low bound parses, high bound does not -> raise on the high bound.
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_range("created", "2020", "garbage", UTC)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "garbage"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestTranslateQuery:
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("raw", "expected"),
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"created:2020",
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z}",
|
|
||||||
),
|
|
||||||
("tag:foo,bar", "tag:foo AND tag:bar"),
|
|
||||||
# 'type' is a user-facing alias rewritten to 'document_type' (the real schema field)
|
|
||||||
("tag:foo,type:bar", "tag:foo AND document_type:bar"),
|
|
||||||
(
|
|
||||||
"created:[2020 TO 2021],added:[2022 TO 2023]",
|
|
||||||
(
|
|
||||||
"created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z}"
|
|
||||||
" AND "
|
|
||||||
"added:[2022-01-01T00:00:00Z TO 2024-01-01T00:00:00Z}"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
# correspondent is not multi-value: comma stays literal inside the value
|
|
||||||
("correspondent:foo,bar", "correspondent:foo,bar"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_golden(self, raw: str, expected: str) -> None:
|
|
||||||
assert translate_query(raw, UTC) == expected
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw",
|
|
||||||
[
|
|
||||||
"created:2020",
|
|
||||||
"created:202003",
|
|
||||||
"created:[20200101 TO 20201231]",
|
|
||||||
"created:[2020-01-01 TO 2020-12-31]",
|
|
||||||
"created:[2020 to]",
|
|
||||||
"created:[to 2020]",
|
|
||||||
"title:x,created:[2020 TO 2021]",
|
|
||||||
"created:2020 OR foo",
|
|
||||||
"(created:2020 OR invoice)",
|
|
||||||
"tag:foo,type:bar",
|
|
||||||
"bank statement",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_parse_acceptance(self, index: tantivy.Index, raw: str) -> None:
|
|
||||||
translated = translate_query(raw, UTC)
|
|
||||||
# Must not raise:
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestFieldAliasing:
|
|
||||||
"""Whoosh->Tantivy field-name aliasing (type/path -> document_type/storage_path)."""
|
|
||||||
|
|
||||||
def test_type_alias(self) -> None:
|
|
||||||
assert translate_query("type:invoice", UTC) == "document_type:invoice"
|
|
||||||
|
|
||||||
def test_path_alias(self) -> None:
|
|
||||||
assert translate_query("path:/foo/bar", UTC) == "storage_path:/foo/bar"
|
|
||||||
|
|
||||||
def test_type_id_alias(self) -> None:
|
|
||||||
assert translate_query("type_id:5", UTC) == "document_type_id:5"
|
|
||||||
|
|
||||||
def test_path_id_alias(self) -> None:
|
|
||||||
assert translate_query("path_id:7", UTC) == "storage_path_id:7"
|
|
||||||
|
|
||||||
def test_clause_separator_plus_alias(self) -> None:
|
|
||||||
# Comma between known fields acts as AND separator; alias still applied.
|
|
||||||
assert (
|
|
||||||
translate_query("tag:foo,type:bar", UTC) == "tag:foo AND document_type:bar"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_type_range_alias(self) -> None:
|
|
||||||
# type is not a date field; range passes through verbatim with alias applied.
|
|
||||||
assert (
|
|
||||||
translate_query("type:[2020 TO 2021]", UTC)
|
|
||||||
== "document_type:[2020 TO 2021]"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_parse_acceptance_type(self, index: tantivy.Index) -> None:
|
|
||||||
# Translated output must be accepted by the real Tantivy parser.
|
|
||||||
translated = translate_query("type:invoice", UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
def test_parse_acceptance_path(self, index: tantivy.Index) -> None:
|
|
||||||
translated = translate_query("path:foo", UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
# Freeze time so relative-date tests are deterministic.
|
|
||||||
_FROZEN_NOW = datetime(2026, 3, 28, 12, 0, 0, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestRelativeRanges:
|
|
||||||
"""Relative date-range tokens resolved against a frozen clock."""
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_7_days_to_now(self) -> None:
|
|
||||||
assert translate_query("added:[-7 days to now]", UTC) == (
|
|
||||||
"added:[2026-03-21T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_1_week_to_now(self) -> None:
|
|
||||||
assert translate_query("added:[-1 week to now]", UTC) == (
|
|
||||||
"added:[2026-03-21T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_1_month_to_now(self) -> None:
|
|
||||||
assert translate_query("created:[-1 month to now]", UTC) == (
|
|
||||||
"created:[2026-02-28T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_1_year_to_now(self) -> None:
|
|
||||||
assert translate_query("modified:[-1 year to now]", UTC) == (
|
|
||||||
"modified:[2025-03-28T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_3_hours_to_now(self) -> None:
|
|
||||||
assert translate_query("added:[-3 hours to now]", UTC) == (
|
|
||||||
"added:[2026-03-28T09:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_uppercase_units(self) -> None:
|
|
||||||
assert translate_query("added:[-1 WEEK TO NOW]", UTC) == (
|
|
||||||
"added:[2026-03-21T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_now_minus_7d_compact(self) -> None:
|
|
||||||
assert translate_query("added:[now-7d TO now]", UTC) == (
|
|
||||||
"added:[2026-03-21T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_reversed_range_swapped(self) -> None:
|
|
||||||
# now+1h TO now-1h is reversed; translate_range swaps -> lo=now-1h, hi=now+1h
|
|
||||||
assert translate_query("added:[now+1h TO now-1h]", UTC) == (
|
|
||||||
"added:[2026-03-28T11:00:00Z TO 2026-03-28T13:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw",
|
|
||||||
[
|
|
||||||
"added:[-7 days to now]",
|
|
||||||
"added:[-1 week to now]",
|
|
||||||
"created:[-1 month to now]",
|
|
||||||
"modified:[-1 year to now]",
|
|
||||||
"added:[-3 hours to now]",
|
|
||||||
"added:[now-7d TO now]",
|
|
||||||
"added:[now+1h TO now-1h]",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_parse_acceptance(self, index: tantivy.Index, raw: str) -> None:
|
|
||||||
translated = translate_query(raw, UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestWhooshUnitAbbreviations:
|
|
||||||
"""
|
|
||||||
Whoosh's PlusMinus date grammar accepted abbreviated unit spellings
|
|
||||||
(e.g. "yrs", "mos", "wks", "hrs", "mins", "secs"); saved views/searches
|
|
||||||
created under the old Whoosh backend can contain those tokens (see
|
|
||||||
https://github.com/paperless-ngx/paperless-ngx/issues/13482), so the
|
|
||||||
Tantivy translator must still accept them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_minus_999_yrs(self) -> None:
|
|
||||||
assert translate_query("created:[-999yrs to now]", UTC) == (
|
|
||||||
"created:[1027-03-28T12:00:00Z TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("token", "expected_lo"),
|
|
||||||
[
|
|
||||||
("-1y", "2025-03-28T12:00:00Z"),
|
|
||||||
("-1yr", "2025-03-28T12:00:00Z"),
|
|
||||||
("-3mos", "2025-12-28T12:00:00Z"),
|
|
||||||
("-3mo", "2025-12-28T12:00:00Z"),
|
|
||||||
("-2wks", "2026-03-14T12:00:00Z"),
|
|
||||||
("-2wk", "2026-03-14T12:00:00Z"),
|
|
||||||
("-5dys", "2026-03-23T12:00:00Z"),
|
|
||||||
("-5dy", "2026-03-23T12:00:00Z"),
|
|
||||||
("-1hrs", "2026-03-28T11:00:00Z"),
|
|
||||||
("-1hr", "2026-03-28T11:00:00Z"),
|
|
||||||
("-10mins", "2026-03-28T11:50:00Z"),
|
|
||||||
("-10min", "2026-03-28T11:50:00Z"),
|
|
||||||
("-30secs", "2026-03-28T11:59:30Z"),
|
|
||||||
("-30sec", "2026-03-28T11:59:30Z"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_abbreviated_units(self, token: str, expected_lo: str) -> None:
|
|
||||||
assert translate_query(f"added:[{token} to now]", UTC) == (
|
|
||||||
f"added:[{expected_lo} TO 2026-03-28T12:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw",
|
|
||||||
[
|
|
||||||
"created:[-999yrs to now]",
|
|
||||||
"added:[-1y to now]",
|
|
||||||
"created:[-3mos to now]",
|
|
||||||
"added:[-2wks to now]",
|
|
||||||
"added:[-5dys to now]",
|
|
||||||
"added:[-1hrs to now]",
|
|
||||||
"added:[-10mins to now]",
|
|
||||||
"added:[-30secs to now]",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_parse_acceptance(self, index: tantivy.Index, raw: str) -> None:
|
|
||||||
translated = translate_query(raw, UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestOperatorNormalization:
|
|
||||||
"""Post-render operator normalization in translate_query."""
|
|
||||||
|
|
||||||
def test_spaced_dash_removed(self) -> None:
|
|
||||||
assert (
|
|
||||||
translate_query("H52.1 - Kurzsichtigkeit", UTC) == "H52.1 Kurzsichtigkeit"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_spaced_dash_simple(self) -> None:
|
|
||||||
assert translate_query("bar - baz", UTC) == "bar baz"
|
|
||||||
|
|
||||||
def test_trailing_operator_stripped(self) -> None:
|
|
||||||
assert translate_query("foo -", UTC) == "foo"
|
|
||||||
|
|
||||||
def test_date_range_preserved(self) -> None:
|
|
||||||
out = translate_query("created:[2020 TO 2021]", UTC)
|
|
||||||
# Must not corrupt the ISO range
|
|
||||||
assert out == "created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z}"
|
|
||||||
|
|
||||||
def test_date_scalar_with_or(self) -> None:
|
|
||||||
out = translate_query("created:2020 OR foo", UTC)
|
|
||||||
# The created scalar becomes a range; " OR foo" passes through verbatim.
|
|
||||||
assert out.startswith("created:[")
|
|
||||||
assert "OR foo" in out
|
|
||||||
|
|
||||||
def test_parse_acceptance_spaced_dash(self, index: tantivy.Index) -> None:
|
|
||||||
translated = translate_query("H52.1 - Kurzsichtigkeit", UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
def test_parse_acceptance_trailing_op(self, index: tantivy.Index) -> None:
|
|
||||||
translated = translate_query("foo -", UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestMultiWordDateKeywords:
|
|
||||||
"""scan() must consume multi-word date keywords as a single value."""
|
|
||||||
|
|
||||||
def test_scan_previous_week_as_single_token(self) -> None:
|
|
||||||
# "created:previous week" must produce one FieldValue with value "previous week",
|
|
||||||
# not FieldValue("created","previous") + Passthrough(" week").
|
|
||||||
toks = scan("created:previous week")
|
|
||||||
assert toks == [FieldValue("created", "previous week")]
|
|
||||||
|
|
||||||
def test_scan_this_month_as_single_token(self) -> None:
|
|
||||||
toks = scan("added:this month")
|
|
||||||
assert toks == [FieldValue("added", "this month")]
|
|
||||||
|
|
||||||
def test_scan_previous_month_as_single_token(self) -> None:
|
|
||||||
toks = scan("created:previous month")
|
|
||||||
assert toks == [FieldValue("created", "previous month")]
|
|
||||||
|
|
||||||
def test_scan_this_year_as_single_token(self) -> None:
|
|
||||||
toks = scan("added:this year")
|
|
||||||
assert toks == [FieldValue("added", "this year")]
|
|
||||||
|
|
||||||
def test_scan_previous_year_as_single_token(self) -> None:
|
|
||||||
toks = scan("created:previous year")
|
|
||||||
assert toks == [FieldValue("created", "previous year")]
|
|
||||||
|
|
||||||
def test_scan_previous_quarter_as_single_token(self) -> None:
|
|
||||||
toks = scan("created:previous quarter")
|
|
||||||
assert toks == [FieldValue("created", "previous quarter")]
|
|
||||||
|
|
||||||
def test_quoted_multi_word_keyword_still_works(self) -> None:
|
|
||||||
# The quoted form must continue to work as before.
|
|
||||||
toks = scan('created:"previous week"')
|
|
||||||
assert toks == [FieldValue("created", '"previous week"')]
|
|
||||||
|
|
||||||
def test_non_date_field_not_affected(self) -> None:
|
|
||||||
# "previous" stops at the space for non-date fields; " week" passes through.
|
|
||||||
toks = scan("correspondent:previous week")
|
|
||||||
assert toks == [
|
|
||||||
FieldValue("correspondent", "previous"),
|
|
||||||
Passthrough(" week"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestKeywordDateResolution:
|
|
||||||
"""Relative date keywords resolve to exact ISO ranges against a frozen clock.
|
|
||||||
|
|
||||||
Frozen at 2026-03-28 12:00 UTC (a Saturday in Q1) so the week, month,
|
|
||||||
quarter and year rollovers are all exercised by a single anchor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# created is a DateField: bounds are UTC midnight, no timezone offset.
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("keyword", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"today",
|
|
||||||
"created:[2026-03-28T00:00:00Z TO 2026-03-29T00:00:00Z}",
|
|
||||||
id="today",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"yesterday",
|
|
||||||
"created:[2026-03-27T00:00:00Z TO 2026-03-28T00:00:00Z}",
|
|
||||||
id="yesterday",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous week",
|
|
||||||
"created:[2026-03-16T00:00:00Z TO 2026-03-23T00:00:00Z}",
|
|
||||||
id="previous-week",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this month",
|
|
||||||
"created:[2026-03-01T00:00:00Z TO 2026-04-01T00:00:00Z}",
|
|
||||||
id="this-month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous month",
|
|
||||||
"created:[2026-02-01T00:00:00Z TO 2026-03-01T00:00:00Z}",
|
|
||||||
id="previous-month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this year",
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2027-01-01T00:00:00Z}",
|
|
||||||
id="this-year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous year",
|
|
||||||
"created:[2025-01-01T00:00:00Z TO 2026-01-01T00:00:00Z}",
|
|
||||||
id="previous-year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous quarter",
|
|
||||||
"created:[2025-10-01T00:00:00Z TO 2026-01-01T00:00:00Z}",
|
|
||||||
id="previous-quarter",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_date_only_field_keyword_ranges(
|
|
||||||
self,
|
|
||||||
keyword: str,
|
|
||||||
expected: str,
|
|
||||||
) -> None:
|
|
||||||
assert translate_query(f"created:{keyword}", UTC) == expected
|
|
||||||
|
|
||||||
# added is a DateTimeField: local-tz midnight converted to UTC. Tokyo
|
|
||||||
# (+09:00, no DST) shifts each midnight boundary back to 15:00Z the day
|
|
||||||
# before, so this also exercises the local-midnight offset path.
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("keyword", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"today",
|
|
||||||
"added:[2026-03-27T15:00:00Z TO 2026-03-28T15:00:00Z}",
|
|
||||||
id="today",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"yesterday",
|
|
||||||
"added:[2026-03-26T15:00:00Z TO 2026-03-27T15:00:00Z}",
|
|
||||||
id="yesterday",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous week",
|
|
||||||
"added:[2026-03-15T15:00:00Z TO 2026-03-22T15:00:00Z}",
|
|
||||||
id="previous-week",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this month",
|
|
||||||
"added:[2026-02-28T15:00:00Z TO 2026-03-31T15:00:00Z}",
|
|
||||||
id="this-month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous month",
|
|
||||||
"added:[2026-01-31T15:00:00Z TO 2026-02-28T15:00:00Z}",
|
|
||||||
id="previous-month",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"this year",
|
|
||||||
"added:[2025-12-31T15:00:00Z TO 2026-12-31T15:00:00Z}",
|
|
||||||
id="this-year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous year",
|
|
||||||
"added:[2024-12-31T15:00:00Z TO 2025-12-31T15:00:00Z}",
|
|
||||||
id="previous-year",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"previous quarter",
|
|
||||||
"added:[2025-09-30T15:00:00Z TO 2025-12-31T15:00:00Z}",
|
|
||||||
id="previous-quarter",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@time_machine.travel(_FROZEN_NOW, tick=False)
|
|
||||||
def test_datetime_field_keyword_ranges_local_tz(
|
|
||||||
self,
|
|
||||||
keyword: str,
|
|
||||||
expected: str,
|
|
||||||
) -> None:
|
|
||||||
assert translate_query(f"added:{keyword}", ZoneInfo("Asia/Tokyo")) == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.search
|
|
||||||
class TestISODatetimeBounds:
|
|
||||||
"""Full ISO datetime tokens in range bounds must be parsed directly."""
|
|
||||||
|
|
||||||
def test_translate_range_iso_bounds_passthrough(self) -> None:
|
|
||||||
# Already-ISO datetime bounds must pass through as-is (exact instant).
|
|
||||||
result = translate_range(
|
|
||||||
"created",
|
|
||||||
"2020-01-01T00:00:00Z",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
UTC,
|
|
||||||
)
|
|
||||||
assert result == "created:[2020-01-01T00:00:00Z TO 2021-01-01T00:00:00Z]"
|
|
||||||
|
|
||||||
def test_translate_query_iso_range_preserved(self) -> None:
|
|
||||||
q = "created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
assert translate_query(q, UTC) == q
|
|
||||||
|
|
||||||
def test_translate_query_comma_separated_iso_ranges(self) -> None:
|
|
||||||
q = (
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z],"
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
)
|
|
||||||
result = translate_query(q, UTC)
|
|
||||||
assert result == (
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
" AND "
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_translate_query_text_before_comma_separated_date_clause(self) -> None:
|
|
||||||
result = translate_query("schäfersee,created:previous year", UTC)
|
|
||||||
assert result == (
|
|
||||||
"schäfersee AND created:[2025-01-01T00:00:00Z TO 2026-01-01T00:00:00Z}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_invalid_iso_datetime_raises(self) -> None:
|
|
||||||
# A token with "T" that is not valid ISO datetime -> raise.
|
|
||||||
with pytest.raises(InvalidDateQuery) as exc_info:
|
|
||||||
translate_range(
|
|
||||||
"created",
|
|
||||||
"2020-01-01T99:00:00Z",
|
|
||||||
"2021-01-01T00:00:00Z",
|
|
||||||
UTC,
|
|
||||||
)
|
|
||||||
assert exc_info.value.field == "created"
|
|
||||||
assert exc_info.value.value == "2020-01-01T99:00:00Z"
|
|
||||||
|
|
||||||
def test_parse_acceptance_iso_bounds(self, index: tantivy.Index) -> None:
|
|
||||||
q = "created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
translated = translate_query(q, UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
|
|
||||||
def test_parse_acceptance_comma_iso_ranges(self, index: tantivy.Index) -> None:
|
|
||||||
q = (
|
|
||||||
"created:[2026-01-01T00:00:00Z TO 2026-06-01T00:00:00Z],"
|
|
||||||
"added:[2026-05-01T00:00:00Z TO 2026-06-01T00:00:00Z]"
|
|
||||||
)
|
|
||||||
translated = translate_query(q, UTC)
|
|
||||||
index.parse_query(translated, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS)
|
|
||||||
@@ -72,10 +72,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
"barcode_enable_tag": None,
|
"barcode_enable_tag": None,
|
||||||
"barcode_tag_mapping": None,
|
"barcode_tag_mapping": None,
|
||||||
"barcode_tag_split": None,
|
"barcode_tag_split": None,
|
||||||
"remote_ocr_engine": None,
|
|
||||||
"remote_ocr_api_key": None,
|
|
||||||
"remote_ocr_endpoint": None,
|
|
||||||
"remote_ocr_mode": None,
|
|
||||||
"ai_enabled": False,
|
"ai_enabled": False,
|
||||||
"llm_embedding_backend": None,
|
"llm_embedding_backend": None,
|
||||||
"llm_embedding_model": None,
|
"llm_embedding_model": None,
|
||||||
@@ -874,49 +870,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
config.refresh_from_db()
|
config.refresh_from_db()
|
||||||
self.assertEqual(config.llm_api_key, None)
|
self.assertEqual(config.llm_api_key, None)
|
||||||
|
|
||||||
def test_update_remote_ocr_api_key(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing config with remote_ocr_api_key specified
|
|
||||||
WHEN:
|
|
||||||
- API to update remote_ocr_api_key is called with all *s
|
|
||||||
- API to update remote_ocr_api_key is called with empty string
|
|
||||||
THEN:
|
|
||||||
- remote_ocr_api_key is unchanged
|
|
||||||
- remote_ocr_api_key is set to None
|
|
||||||
"""
|
|
||||||
config = ApplicationConfiguration.objects.first()
|
|
||||||
assert config is not None
|
|
||||||
config.remote_ocr_api_key = "1234567890"
|
|
||||||
config.save()
|
|
||||||
|
|
||||||
# Test with all *
|
|
||||||
response = self.client.patch(
|
|
||||||
f"{self.ENDPOINT}1/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"remote_ocr_api_key": "*" * 32,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
config.refresh_from_db()
|
|
||||||
self.assertEqual(config.remote_ocr_api_key, "1234567890")
|
|
||||||
# Test with empty string
|
|
||||||
response = self.client.patch(
|
|
||||||
f"{self.ENDPOINT}1/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"remote_ocr_api_key": "",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
config.refresh_from_db()
|
|
||||||
self.assertEqual(config.remote_ocr_api_key, None)
|
|
||||||
|
|
||||||
def test_enable_ai_index_triggers_update(self) -> None:
|
def test_enable_ai_index_triggers_update(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -339,3 +339,21 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, 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_bad_search_query_returns_400(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "added:notadate"},
|
||||||
|
"content": "originals",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A user-fixable query error must surface as a 400 naming the bad
|
||||||
|
# value, exactly like the search list endpoint, never a 500.
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"notadate", response.content)
|
||||||
|
|||||||
@@ -532,29 +532,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
args, kwargs = m.call_args
|
args, kwargs = m.call_args
|
||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(kwargs, {"remote_ocr": False})
|
self.assertEqual(len(kwargs), 0)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.reprocess")
|
|
||||||
def test_reprocess_documents_endpoint_remote_ocr(self, m) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- API data to reprocess a document with remote OCR requested
|
|
||||||
WHEN:
|
|
||||||
- API is called
|
|
||||||
THEN:
|
|
||||||
- reprocess is called with remote_ocr=True
|
|
||||||
"""
|
|
||||||
self.setup_mock(m, "reprocess")
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/reprocess/",
|
|
||||||
json.dumps({"documents": [self.doc1.id], "remote_ocr": True}),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
m.assert_called_once()
|
|
||||||
args, kwargs = m.call_args
|
|
||||||
self.assertEqual(args[0], [self.doc1.id])
|
|
||||||
self.assertEqual(kwargs, {"remote_ocr": True})
|
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_set_storage_path(self, m) -> None:
|
def test_api_set_storage_path(self, m) -> None:
|
||||||
@@ -1575,29 +1553,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_legacy_bulk_edit_reprocess_invalid_remote_ocr(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- The deprecated bulk_edit endpoint with a non-boolean remote_ocr
|
|
||||||
WHEN:
|
|
||||||
- API is called
|
|
||||||
THEN:
|
|
||||||
- The request is rejected rather than passed through to the task
|
|
||||||
"""
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/bulk_edit/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc1.id],
|
|
||||||
"method": "reprocess",
|
|
||||||
"parameters": {"remote_ocr": "yes please"},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf(self, m) -> None:
|
def test_edit_pdf(self, m) -> None:
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
@@ -2021,3 +1976,22 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
|
self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
|
||||||
|
|
||||||
|
def test_api_bulk_edit_with_bad_search_query_returns_400(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "added:notadate"},
|
||||||
|
"method": "set_storage_path",
|
||||||
|
"parameters": {"storage_path": self.sp1.id},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A user-fixable query error must surface as a 400 naming the bad
|
||||||
|
# value, exactly like the search list endpoint, never a 500.
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"notadate", response.content)
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ from unittest.mock import ANY
|
|||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import connection
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -15,9 +13,6 @@ from rest_framework.test import APITestCase
|
|||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
from documents.models import CustomFieldInstance
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.serialisers import CustomFieldInstanceSerializer
|
|
||||||
from documents.serialisers import DocumentSerializer
|
|
||||||
from documents.tests.factories import DocumentFactory
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -535,137 +530,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
doc.refresh_from_db()
|
doc.refresh_from_db()
|
||||||
self.assertEqual(len(doc.custom_fields.all()), 10)
|
self.assertEqual(len(doc.custom_fields.all()), 10)
|
||||||
|
|
||||||
def test_document_serializer_custom_fields_validation_batches_field_lookup(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document is being validated with several custom field values
|
|
||||||
at once (as happens on every PATCH/PUT/POST)
|
|
||||||
WHEN:
|
|
||||||
- The serializer is validated
|
|
||||||
THEN:
|
|
||||||
- The referenced CustomField objects are resolved with a single
|
|
||||||
query, not one query per custom field
|
|
||||||
"""
|
|
||||||
doc = DocumentFactory(mime_type="application/pdf")
|
|
||||||
custom_fields = [
|
|
||||||
CustomField.objects.create(
|
|
||||||
name=f"Test Custom Field {i}",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
for i in range(5)
|
|
||||||
]
|
|
||||||
|
|
||||||
serializer = DocumentSerializer(
|
|
||||||
doc,
|
|
||||||
data={
|
|
||||||
"custom_fields": [
|
|
||||||
{"field": custom_field.id, "value": "test value"}
|
|
||||||
for custom_field in custom_fields
|
|
||||||
],
|
|
||||||
},
|
|
||||||
partial=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
|
||||||
|
|
||||||
custom_field_lookups = [
|
|
||||||
query
|
|
||||||
for query in ctx.captured_queries
|
|
||||||
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
|
|
||||||
in query["sql"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
len(custom_field_lookups),
|
|
||||||
1,
|
|
||||||
"Expected a single batched query to resolve the custom fields, "
|
|
||||||
f"got {len(custom_field_lookups)}: {custom_field_lookups}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_custom_field_lookup_reuses_shared_context_cache(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A CustomField has already been resolved once, by a serializer
|
|
||||||
sharing a given `context` dict
|
|
||||||
WHEN:
|
|
||||||
- A second, separately-instantiated CustomFieldInstanceSerializer
|
|
||||||
validates the same field id, sharing that same context
|
|
||||||
(this mirrors what real call sites do, e.g. bulk-edit's
|
|
||||||
validate_custom_fields()/_validate_custom_field_values()
|
|
||||||
constructing a fresh CustomFieldInstanceSerializer per
|
|
||||||
submitted field, all sharing the outer serializer's context)
|
|
||||||
THEN:
|
|
||||||
- No additional query is issued to resolve the CustomField
|
|
||||||
"""
|
|
||||||
custom_field = CustomField.objects.create(
|
|
||||||
name="Test Custom Field",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
|
|
||||||
context: dict = {}
|
|
||||||
first_pass = CustomFieldInstanceSerializer(
|
|
||||||
data={"field": custom_field.id, "value": "a"},
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
self.assertTrue(first_pass.is_valid(), first_pass.errors)
|
|
||||||
|
|
||||||
second_pass = CustomFieldInstanceSerializer(
|
|
||||||
data={"field": custom_field.id, "value": "b"},
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
self.assertTrue(second_pass.is_valid(), second_pass.errors)
|
|
||||||
|
|
||||||
custom_field_lookups = [
|
|
||||||
query
|
|
||||||
for query in ctx.captured_queries
|
|
||||||
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
|
|
||||||
in query["sql"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
len(custom_field_lookups),
|
|
||||||
0,
|
|
||||||
"Expected the second, separately-instantiated serializer to reuse "
|
|
||||||
f"the already-resolved CustomField, got: {custom_field_lookups}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_custom_field_validation_rejects_malformed_field_value(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document is being validated with a malformed custom_fields
|
|
||||||
entry whose "field" value is neither a valid CustomField id
|
|
||||||
nor a type DRF's own PrimaryKeyRelatedField can safely reject
|
|
||||||
on its own (unhashable, or a non-numeric scalar)
|
|
||||||
WHEN:
|
|
||||||
- The serializer is validated
|
|
||||||
THEN:
|
|
||||||
- A normal validation error is raised, not an unhandled
|
|
||||||
TypeError/ValueError escaping past DRF's validation layer
|
|
||||||
"""
|
|
||||||
doc = DocumentFactory(mime_type="application/pdf")
|
|
||||||
|
|
||||||
bad_field_values = {
|
|
||||||
"unhashable-list": [],
|
|
||||||
"unhashable-dict": {},
|
|
||||||
"non-numeric-scalar": "abc",
|
|
||||||
}
|
|
||||||
for case_id, bad_field_value in bad_field_values.items():
|
|
||||||
with self.subTest(case_id):
|
|
||||||
serializer = DocumentSerializer(
|
|
||||||
doc,
|
|
||||||
data={
|
|
||||||
"custom_fields": [
|
|
||||||
{"field": bad_field_value, "value": "test value"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
partial=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertFalse(serializer.is_valid())
|
|
||||||
self.assertIn("custom_fields", serializer.errors)
|
|
||||||
|
|
||||||
def test_change_custom_field_instance_value(self) -> None:
|
def test_change_custom_field_instance_value(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -796,72 +660,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
assert _cf_4 is not None
|
assert _cf_4 is not None
|
||||||
self.assertEqual(_cf_4.value, date_value)
|
self.assertEqual(_cf_4.value, date_value)
|
||||||
|
|
||||||
def test_delete_custom_field_instance_is_hard_deleted_not_soft_deleted(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document has two custom field instances
|
|
||||||
WHEN:
|
|
||||||
- A PATCH request updates custom_fields to omit one of them
|
|
||||||
THEN:
|
|
||||||
- The omitted instance is immediately hard-deleted: it is gone
|
|
||||||
from both the default manager and the soft-deleted manager,
|
|
||||||
not left in a soft-deleted, not-yet-purged state
|
|
||||||
"""
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="WOW",
|
|
||||||
content="the content",
|
|
||||||
checksum="123-hard-delete",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
kept_field = CustomField.objects.create(
|
|
||||||
name="Kept Field",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
removed_field = CustomField.objects.create(
|
|
||||||
name="Removed Field",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = self.client.patch(
|
|
||||||
f"/api/documents/{doc.id}/",
|
|
||||||
data={
|
|
||||||
"custom_fields": [
|
|
||||||
{"field": kept_field.id, "value": "keep me"},
|
|
||||||
{"field": removed_field.id, "value": "remove me"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(CustomFieldInstance.objects.count(), 2)
|
|
||||||
|
|
||||||
resp = self.client.patch(
|
|
||||||
f"/api/documents/{doc.id}/",
|
|
||||||
data={
|
|
||||||
"custom_fields": [
|
|
||||||
{"field": kept_field.id, "value": "keep me"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
self.assertEqual(CustomFieldInstance.objects.count(), 1)
|
|
||||||
self.assertEqual(
|
|
||||||
CustomFieldInstance.deleted_objects.filter(field=removed_field).count(),
|
|
||||||
0,
|
|
||||||
"Removed custom field instance should be hard-deleted, not left "
|
|
||||||
"soft-deleted",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
CustomFieldInstance.global_objects.filter(field=removed_field).count(),
|
|
||||||
0,
|
|
||||||
"Removed custom field instance should not exist at all, even in "
|
|
||||||
"the all-rows manager",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_custom_field_validation(self) -> None:
|
def test_custom_field_validation(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1553,81 +1351,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(results[0]["document_count"], 0)
|
self.assertEqual(results[0]["document_count"], 0)
|
||||||
|
|
||||||
def test_document_update_custom_fields_sync_query_count(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document already has 3 custom field instances attached
|
|
||||||
WHEN:
|
|
||||||
- A PATCH request updates 2 of them, adds 1 new one, and omits
|
|
||||||
the 3rd (which should be deleted)
|
|
||||||
THEN:
|
|
||||||
- The omitted custom field is removed via exactly one
|
|
||||||
delete-shaped query, not drf-writable-nested's old two-step
|
|
||||||
soft-delete-then-hard-delete-after pattern (two delete
|
|
||||||
passes)
|
|
||||||
"""
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="WOW",
|
|
||||||
content="the content",
|
|
||||||
checksum="123-sync-count",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
fields = [
|
|
||||||
CustomField.objects.create(
|
|
||||||
name=f"Sync Field {i}",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
for i in range(4)
|
|
||||||
]
|
|
||||||
# Attach the first 3 up front; the 4th is added in the PATCH below,
|
|
||||||
# and the 3rd is omitted (so it should be deleted).
|
|
||||||
for field in fields[:3]:
|
|
||||||
CustomFieldInstance.objects.create(
|
|
||||||
document=doc,
|
|
||||||
field=field,
|
|
||||||
value_text="initial",
|
|
||||||
)
|
|
||||||
self.assertEqual(CustomFieldInstance.objects.count(), 3)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
resp = self.client.patch(
|
|
||||||
f"/api/documents/{doc.id}/",
|
|
||||||
data={
|
|
||||||
"custom_fields": [
|
|
||||||
{"field": fields[0].id, "value": "updated 0"},
|
|
||||||
{"field": fields[1].id, "value": "updated 1"},
|
|
||||||
{"field": fields[3].id, "value": "new 3"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
delete_queries = [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if 'DELETE FROM "documents_customfieldinstance"' in q["sql"]
|
|
||||||
or (
|
|
||||||
'UPDATE "documents_customfieldinstance"' in q["sql"]
|
|
||||||
and '"deleted_at"' in q["sql"]
|
|
||||||
and '"deleted_at" = NULL' not in q["sql"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
len(delete_queries),
|
|
||||||
1,
|
|
||||||
"Expected exactly one delete/soft-delete-marking query for the "
|
|
||||||
f"omitted custom field, got {len(delete_queries)}: {delete_queries}",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(CustomFieldInstance.objects.count(), 3)
|
|
||||||
doc.refresh_from_db()
|
|
||||||
values = {cfi.field_id: cfi.value for cfi in doc.custom_fields.all()}
|
|
||||||
self.assertEqual(values[fields[0].id], "updated 0")
|
|
||||||
self.assertEqual(values[fields[1].id], "updated 1")
|
|
||||||
self.assertEqual(values[fields[3].id], "new 3")
|
|
||||||
self.assertNotIn(fields[2].id, values)
|
|
||||||
|
|
||||||
def test_patch_document_invalid_date_custom_field_returns_validation_error(
|
def test_patch_document_invalid_date_custom_field_returns_validation_error(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
@@ -22,9 +21,7 @@ from django.core import mail
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.db import DataError
|
from django.db import DataError
|
||||||
from django.db import connection
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -255,82 +252,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
doc.refresh_from_db()
|
doc.refresh_from_db()
|
||||||
self.assertEqual(doc.created, date(2023, 6, 28))
|
self.assertEqual(doc.created, date(2023, 6, 28))
|
||||||
|
|
||||||
def test_document_update_tags_batches_tag_lookup(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document is being updated with several tags at once
|
|
||||||
WHEN:
|
|
||||||
- API PATCH request is made setting the document's tags
|
|
||||||
THEN:
|
|
||||||
- The referenced Tag objects are resolved with a single batched
|
|
||||||
query, not one query per tag
|
|
||||||
"""
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="none",
|
|
||||||
checksum="123",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
tags = [TagFactory() for _ in range(8)]
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = self.client.patch(
|
|
||||||
f"/api/documents/{doc.pk}/",
|
|
||||||
{"tags": [t.id for t in tags]},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
# Match `"documents_tag"."id" = <literal>` (a single-row WHERE lookup)
|
|
||||||
# but not the same substring appearing as a JOIN's ON condition
|
|
||||||
# (`"documents_tag"."id" = "documents_document_tags"."tag_id"`),
|
|
||||||
# which is a legitimate, unrelated response-serialization query.
|
|
||||||
single_tag_lookup_re = re.compile(r'"documents_tag"\."id" = \d')
|
|
||||||
single_tag_lookups = [
|
|
||||||
q for q in ctx.captured_queries if single_tag_lookup_re.search(q["sql"])
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
len(single_tag_lookups),
|
|
||||||
0,
|
|
||||||
"Expected tags to be resolved with a batched query, not "
|
|
||||||
f"per-tag lookups, got: {single_tag_lookups}",
|
|
||||||
)
|
|
||||||
|
|
||||||
doc.refresh_from_db()
|
|
||||||
self.assertCountEqual(
|
|
||||||
doc.tags.values_list("id", flat=True),
|
|
||||||
[t.id for t in tags],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_document_update_tags_rejects_out_of_range_id(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document is being updated with a tag id too large for the
|
|
||||||
database's integer column
|
|
||||||
WHEN:
|
|
||||||
- API PATCH request is made setting the document's tags
|
|
||||||
THEN:
|
|
||||||
- A normal 400 validation error is returned, not an unhandled
|
|
||||||
OverflowError/DataError escaping as a 500
|
|
||||||
|
|
||||||
Django's IntegerFieldOverflow guard converts an out-of-range int
|
|
||||||
into a clean "no match" for exact/gt/gte/lt/lte lookups, but not for
|
|
||||||
`in` -- the batched tag resolution uses `pk__in=`, so this has to be
|
|
||||||
guarded explicitly rather than relying on Django to do it.
|
|
||||||
"""
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="none",
|
|
||||||
checksum="123",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.patch(
|
|
||||||
f"/api/documents/{doc.pk}/",
|
|
||||||
{"tags": [99999999999999999999999999999]},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def test_document_update_legacy_created_format(self) -> None:
|
def test_document_update_legacy_created_format(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -2,15 +2,10 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import connection
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
|
||||||
from guardian.shortcuts import get_users_with_perms
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -820,59 +815,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_set_permissions_query_count_independent_of_object_count(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Many tags are being bulk-edited to set permissions at once
|
|
||||||
WHEN:
|
|
||||||
- bulk_edit_objects API endpoint is called with set_permissions
|
|
||||||
operation over a small batch vs. a much larger one
|
|
||||||
THEN:
|
|
||||||
- The number of queries issued is the same either way -- each
|
|
||||||
user/group is applied across all tags with one batched call,
|
|
||||||
not one call per (tag, user) pair
|
|
||||||
"""
|
|
||||||
group1 = Group.objects.create(name="perm-group")
|
|
||||||
permissions = {
|
|
||||||
"view": {"users": [self.user1.id, self.user2.id], "groups": [group1.id]},
|
|
||||||
"change": {"users": [self.user1.id], "groups": [group1.id]},
|
|
||||||
}
|
|
||||||
|
|
||||||
def run_with_n_tags(n: int) -> int:
|
|
||||||
tags = [Tag.objects.create(name=f"perm-tag-{n}-{i}") for i in range(n)]
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/bulk_edit_objects/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"objects": [t.id for t in tags],
|
|
||||||
"object_type": "tags",
|
|
||||||
"operation": "set_permissions",
|
|
||||||
"permissions": permissions,
|
|
||||||
"merge": False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
for tag in tags:
|
|
||||||
self.assertEqual(get_users_with_perms(tag).count(), 2)
|
|
||||||
self.assertEqual(get_groups_with_perms(tag).count(), 1)
|
|
||||||
return len(ctx.captured_queries)
|
|
||||||
|
|
||||||
small_batch_queries = run_with_n_tags(5)
|
|
||||||
large_batch_queries = run_with_n_tags(50)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
small_batch_queries,
|
|
||||||
large_batch_queries,
|
|
||||||
"Expected the same query count regardless of tag count, got "
|
|
||||||
f"{small_batch_queries} queries for 5 tags vs. "
|
|
||||||
f"{large_batch_queries} for 50",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_bulk_objects_delete_all_filtered(self) -> None:
|
def test_bulk_objects_delete_all_filtered(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -756,6 +756,10 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
tick=False,
|
tick=False,
|
||||||
):
|
):
|
||||||
response = self.client.get("/api/documents/?query=added:previous month")
|
response = self.client.get("/api/documents/?query=added:previous month")
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f"expected a successful search response, got {response.status_code}: "
|
||||||
|
f"{response.data!r}"
|
||||||
|
)
|
||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
|
|
||||||
self.assertEqual(len(results), 1)
|
self.assertEqual(len(results), 1)
|
||||||
@@ -788,6 +792,26 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("invalid-date", str(response.data["query"]))
|
self.assertIn("invalid-date", str(response.data["query"]))
|
||||||
|
|
||||||
|
def test_search_multiple_bad_fields_returns_all_messages(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- One document added
|
||||||
|
WHEN:
|
||||||
|
- Query with multiple bad fields (e.g. invalid date and invalid number)
|
||||||
|
THEN:
|
||||||
|
- 400 Bad Request with error messages for every bad field,
|
||||||
|
so the user can fix them all in one round-trip
|
||||||
|
"""
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/documents/",
|
||||||
|
{"query": "created:notadate AND asn:notanumber"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
messages = response.data["query"]
|
||||||
|
self.assertEqual(len(messages), 2)
|
||||||
|
self.assertTrue(any("created" in m for m in messages))
|
||||||
|
self.assertTrue(any("asn" in m for m in messages))
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
TIME_ZONE="UTC",
|
TIME_ZONE="UTC",
|
||||||
)
|
)
|
||||||
@@ -831,6 +855,29 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual({r["id"] for r in results}, {1, 2})
|
self.assertEqual({r["id"] for r in results}, {1, 2})
|
||||||
|
|
||||||
|
@mock.patch("documents.search._backend.parse_user_query")
|
||||||
|
def test_search_parser_bug_surfaces_as_500_not_400(self, m) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- The query parser itself fails (a whoosh-compat bug, per
|
||||||
|
QueryParserError's own contract: not user-fixable input)
|
||||||
|
WHEN:
|
||||||
|
- Any search request runs
|
||||||
|
THEN:
|
||||||
|
- The error surfaces as a 500 monitoring can see, never a 400
|
||||||
|
blaming the user for a library defect
|
||||||
|
"""
|
||||||
|
from whoosh_compat.errors import QueryParserError
|
||||||
|
|
||||||
|
m.side_effect = QueryParserError("synthetic parser bug")
|
||||||
|
|
||||||
|
self.client.raise_request_exception = False
|
||||||
|
response = self.client.get("/api/documents/?query=anything")
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch("documents.search._backend.TantivyBackend.autocomplete")
|
@mock.patch("documents.search._backend.TantivyBackend.autocomplete")
|
||||||
def test_search_autocomplete_limits(self, m) -> None:
|
def test_search_autocomplete_limits(self, m) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -2005,3 +2052,45 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
response = self.client.get("/api/search/?query=no")
|
response = self.client.get("/api/search/?query=no")
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def _assert_query_finds(self, doc: Document, query: str) -> None:
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
response = self.client.get("/api/documents/", {"query": query})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
ids = [r["id"] for r in response.data["results"]]
|
||||||
|
self.assertIn(doc.id, ids)
|
||||||
|
|
||||||
|
def test_search_by_asn(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Has ASN",
|
||||||
|
content="content",
|
||||||
|
checksum="asn-checksum",
|
||||||
|
archive_serial_number=555,
|
||||||
|
)
|
||||||
|
self._assert_query_finds(doc, "asn:555")
|
||||||
|
|
||||||
|
def test_search_by_page_count(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Multi-page",
|
||||||
|
content="content",
|
||||||
|
checksum="page-count-checksum",
|
||||||
|
page_count=42,
|
||||||
|
)
|
||||||
|
self._assert_query_finds(doc, "page_count:42")
|
||||||
|
|
||||||
|
def test_search_by_original_filename(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Named file",
|
||||||
|
content="content",
|
||||||
|
checksum="filename-checksum",
|
||||||
|
original_filename="quarterly-report.pdf",
|
||||||
|
)
|
||||||
|
self._assert_query_finds(doc, "original_filename:quarterly-report.pdf")
|
||||||
|
|
||||||
|
def test_search_by_checksum(self) -> None:
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Checksum doc",
|
||||||
|
content="content",
|
||||||
|
checksum="deadbeef1234",
|
||||||
|
)
|
||||||
|
self._assert_query_finds(doc, "checksum:deadbeef1234")
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""The search list endpoint's exception handling: what becomes a 400 and
|
||||||
|
what a library defect surfaces as instead.
|
||||||
|
|
||||||
|
Companion to documents/tests/search/test_error_routing.py, which pins the
|
||||||
|
Cause -> SearchQueryError/QueryError routing inside documents/search/_query.py.
|
||||||
|
These tests pin the layer above it: DocumentViewSet.list's own except clauses,
|
||||||
|
which decide what an already-routed error becomes on the wire.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework import status
|
||||||
|
from whoosh_compat.errors import Cause
|
||||||
|
from whoosh_compat.errors import Diagnostic
|
||||||
|
from whoosh_compat.errors import DiagnosticKind
|
||||||
|
from whoosh_compat.errors import QueryError
|
||||||
|
|
||||||
|
from documents.search import SearchQueryError
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("_search_index")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed_document() -> Document:
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(title="quarterly invoice", content="acme corp")
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchQueryErrorStillBecomesA400:
|
||||||
|
def test_search_query_error_becomes_a_400_naming_the_field(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_search_query_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise SearchQueryError("bad value for field 'added'")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_search_query_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.get("/api/documents/?query=anything")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "added" in str(response.data["query"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLibraryDefectsPropagate:
|
||||||
|
"""The exact regression this task exists to fix: an unexpected or
|
||||||
|
INTERNAL-cause library error must not be relabeled a 400."""
|
||||||
|
|
||||||
|
def test_unexpected_exception_is_not_converted_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_zero_division(*args: object, **kwargs: object) -> object:
|
||||||
|
raise ZeroDivisionError("synthetic bug, unrelated to search grammar")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_zero_division,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ZeroDivisionError):
|
||||||
|
admin_client.get("/api/documents/?query=anything")
|
||||||
|
|
||||||
|
def test_internal_cause_query_error_is_not_converted_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
"""Forces the one library-internal failure mode reachable from a real
|
||||||
|
query: emit() reporting a defect in itself (Cause.INTERNAL) after a
|
||||||
|
real query string went through the real parse and routing pipeline.
|
||||||
|
|
||||||
|
``tantivy_emit`` (the whoosh-compat emitter) is monkeypatched rather
|
||||||
|
than ``parse_user_query`` itself, so everything upstream of it --
|
||||||
|
the pre-parse rewrites, ``wc.parse()``, and ``_map_emit_error``'s own
|
||||||
|
Cause routing in documents/search/_query.py -- runs for real; only
|
||||||
|
the final emit call is forced to report the defect.
|
||||||
|
"""
|
||||||
|
import documents.search._query as query_mod
|
||||||
|
|
||||||
|
def raise_internal(*args: object, **kwargs: object) -> object:
|
||||||
|
raise QueryError(
|
||||||
|
Diagnostic(
|
||||||
|
kind=DiagnosticKind.BACKEND_REJECTED,
|
||||||
|
cause=Cause.INTERNAL,
|
||||||
|
message="synthetic whoosh-compat emitter defect",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(query_mod, "tantivy_emit", raise_internal)
|
||||||
|
|
||||||
|
with pytest.raises(QueryError):
|
||||||
|
admin_client.get("/api/documents/?query=invoice")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectionPathsAgreeWithSearch:
|
||||||
|
"""DocumentSelectionMixin backs bulk edit, bulk download, and a
|
||||||
|
more_like_id selection filter. It catches only SearchQueryError -- the
|
||||||
|
same contract the search list endpoint enforces above -- so all three
|
||||||
|
must map SearchQueryError to a 400 and let anything else surface."""
|
||||||
|
|
||||||
|
def test_bulk_edit_maps_search_query_error_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_search_query_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise SearchQueryError("bad value for field 'added'")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_search_query_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "anything"},
|
||||||
|
"method": "set_document_type",
|
||||||
|
"parameters": {"document_type": None},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "added" in str(response.data["query"])
|
||||||
|
|
||||||
|
def test_bulk_edit_lets_an_unexpected_exception_surface(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_zero_division(*args: object, **kwargs: object) -> object:
|
||||||
|
raise ZeroDivisionError("synthetic bug, unrelated to search grammar")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_zero_division,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ZeroDivisionError):
|
||||||
|
admin_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "anything"},
|
||||||
|
"method": "set_document_type",
|
||||||
|
"parameters": {"document_type": None},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bulk_download_maps_search_query_error_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_search_query_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise SearchQueryError("bad value for field 'added'")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod,
|
||||||
|
"parse_user_query",
|
||||||
|
raise_search_query_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": "anything"},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "added" in str(response.data["query"])
|
||||||
|
|
||||||
|
def test_more_like_id_selection_filter_maps_search_query_error_to_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_search_query_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise SearchQueryError("similar-document lookup is unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod.TantivyBackend,
|
||||||
|
"more_like_this_ids",
|
||||||
|
raise_search_query_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"more_like_id": indexed_document.pk},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
def test_more_like_id_selection_filter_lets_an_unexpected_exception_surface(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
import documents.search._backend as backend_mod
|
||||||
|
|
||||||
|
def raise_zero_division(*args: object, **kwargs: object) -> object:
|
||||||
|
raise ZeroDivisionError("synthetic bug, unrelated to similarity lookup")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend_mod.TantivyBackend,
|
||||||
|
"more_like_this_ids",
|
||||||
|
raise_zero_division,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ZeroDivisionError):
|
||||||
|
admin_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"more_like_id": indexed_document.pk},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""The query-length cap in ``_get_tantivy_query_and_mode`` (F3).
|
||||||
|
|
||||||
|
whoosh-compat's fieldname tagger is O(n^2) in plain word characters, so an
|
||||||
|
unbounded ``query`` (SearchMode.QUERY) string is a CPU-exhaustion vector
|
||||||
|
against a single request handler. The GET search endpoint is incidentally
|
||||||
|
bounded by the web server's header limit, but the POST selection-filter
|
||||||
|
path (bulk edit, bulk download) is not -- that is the real vector, so it
|
||||||
|
must be pinned here too, not just the GET path.
|
||||||
|
|
||||||
|
The cap is enforced once, in the shared helper both entry points call, so
|
||||||
|
these tests exercise the real endpoints rather than the helper directly:
|
||||||
|
a construct that looks right in isolation has repeatedly behaved
|
||||||
|
differently end to end on this branch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
from documents.views import _MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("_search_index")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed_document() -> Document:
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(title="quarterly invoice", content="acme corp")
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSearchEndpointEnforcesTheCap:
|
||||||
|
def test_query_one_over_the_cap_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
query = "a" * (_MAX_QUERY_LENGTH + 1)
|
||||||
|
|
||||||
|
response = admin_client.get("/api/documents/", {"query": query})
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
message = str(response.data["query"])
|
||||||
|
assert str(_MAX_QUERY_LENGTH) in message
|
||||||
|
assert str(_MAX_QUERY_LENGTH + 1) in message
|
||||||
|
|
||||||
|
def test_query_at_exactly_the_cap_is_accepted(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
query = "a" * _MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
response = admin_client.get("/api/documents/", {"query": query})
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
|
||||||
|
def test_an_ordinary_query_is_unaffected(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get("/api/documents/", {"query": "invoice"})
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostSelectionPathsEnforceTheCap:
|
||||||
|
"""The bulk-edit and bulk-download selection filters share the same
|
||||||
|
helper the GET search path uses. This is the path that actually
|
||||||
|
matters: it is not bounded by a web server's header-length limit the
|
||||||
|
way the GET path incidentally is."""
|
||||||
|
|
||||||
|
def test_bulk_edit_query_one_over_the_cap_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
query = "a" * (_MAX_QUERY_LENGTH + 1)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": query},
|
||||||
|
"method": "set_document_type",
|
||||||
|
"parameters": {"document_type": None},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
message = str(response.data["query"])
|
||||||
|
assert str(_MAX_QUERY_LENGTH) in message
|
||||||
|
assert str(_MAX_QUERY_LENGTH + 1) in message
|
||||||
|
|
||||||
|
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||||
|
def test_bulk_edit_query_at_exactly_the_cap_is_accepted(
|
||||||
|
self,
|
||||||
|
bulk_update_task_mock: mock.MagicMock,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
# The cap check must accept this query and let the request reach the
|
||||||
|
# real bulk-edit method; nothing here is testing that method itself,
|
||||||
|
# so the Celery dispatch it makes is mocked out, same as every other
|
||||||
|
# bulk-edit test (test_api_bulk_edit.py) does.
|
||||||
|
query = "a" * _MAX_QUERY_LENGTH
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": query},
|
||||||
|
"method": "set_document_type",
|
||||||
|
"parameters": {"document_type": None},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
|
||||||
|
def test_bulk_download_query_one_over_the_cap_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
query = "a" * (_MAX_QUERY_LENGTH + 1)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{
|
||||||
|
"documents": [],
|
||||||
|
"all": True,
|
||||||
|
"filters": {"query": query},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
message = str(response.data["query"])
|
||||||
|
assert str(_MAX_QUERY_LENGTH) in message
|
||||||
|
assert str(_MAX_QUERY_LENGTH + 1) in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalSearchEnforcesTheCapToo:
|
||||||
|
"""GlobalSearchView calls the backend directly, not through the shared helper.
|
||||||
|
|
||||||
|
It hardcodes SearchMode.TEXT, which is linear rather than quadratic, so it
|
||||||
|
was never the CPU-exhaustion vector. It is capped anyway so that "every
|
||||||
|
user query string reaching the backend passes a length check" is an
|
||||||
|
invariant rather than a claim with an exception: the view already bounds
|
||||||
|
the query from below, and a later change letting it select a mode would
|
||||||
|
otherwise reopen the hole silently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_query_one_over_the_cap_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get(
|
||||||
|
"/api/search/",
|
||||||
|
{"query": "a" * (_MAX_QUERY_LENGTH + 1)},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
def test_query_at_exactly_the_cap_is_accepted(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get(
|
||||||
|
"/api/search/",
|
||||||
|
{"query": "a" * _MAX_QUERY_LENGTH},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""An unterminated ``[`` date range bracket at the API level.
|
||||||
|
|
||||||
|
``created:[2020`` (with or without a dangling ``to <value>``) now raises
|
||||||
|
BAD_DATE and the search endpoint returns HTTP 400, where it used to parse
|
||||||
|
past the missing ``]`` and silently pass the malformed range through.
|
||||||
|
A 400 is correct: malformed input should fail loudly rather than silently
|
||||||
|
matching an unintended query. Pinned at the API level -- the layer a user
|
||||||
|
or client actually sees -- rather than only against the parser directly.
|
||||||
|
|
||||||
|
The properly closed decoy proves the bracket is what matters, not
|
||||||
|
whoosh-compat's date grammar generally: ``created:[2020 to 2021]`` parses
|
||||||
|
and searches cleanly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("_search_index")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def indexed_document() -> Document:
|
||||||
|
from documents.search import get_backend
|
||||||
|
|
||||||
|
doc = DocumentFactory.create(title="quarterly invoice", content="acme corp")
|
||||||
|
get_backend().add_or_update(doc)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnterminatedBracketReturnsA400:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
pytest.param("created:[2020", id="missing_upper_bound_and_bracket"),
|
||||||
|
pytest.param("created:[2020 to 2021", id="missing_closing_bracket"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unterminated_bracket_is_a_400(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
query: str,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get(f"/api/documents/?query={query}")
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "created" in str(response.data["query"])
|
||||||
|
|
||||||
|
def test_properly_closed_bracket_still_searches_cleanly(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
indexed_document: Document,
|
||||||
|
) -> None:
|
||||||
|
response = admin_client.get(
|
||||||
|
"/api/documents/?query=created:[2020 to 2021]",
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -60,10 +60,6 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
|||||||
},
|
},
|
||||||
"email_enabled": False,
|
"email_enabled": False,
|
||||||
"ai_enabled": False,
|
"ai_enabled": False,
|
||||||
"remote_ocr": {
|
|
||||||
"configured": False,
|
|
||||||
"mode": "always",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,50 +154,6 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
|||||||
str(response.data["settings"]),
|
str(response.data["settings"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
REMOTE_OCR_ENGINE="azureai",
|
|
||||||
REMOTE_OCR_API_KEY="somekey",
|
|
||||||
REMOTE_OCR_ENDPOINT="https://example.cognitiveservices.azure.com",
|
|
||||||
REMOTE_OCR_MODE="workflow_only",
|
|
||||||
)
|
|
||||||
def test_settings_reports_remote_ocr_when_configured(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A fully configured remote OCR engine in workflow_only mode
|
|
||||||
WHEN:
|
|
||||||
- The ui_settings endpoint is called
|
|
||||||
THEN:
|
|
||||||
- The UI is told remote OCR is available and selective, so it can
|
|
||||||
offer it where it would actually change something
|
|
||||||
"""
|
|
||||||
response = self.client.get(self.ENDPOINT, format="json")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(
|
|
||||||
response.data["settings"]["remote_ocr"],
|
|
||||||
{"configured": True, "mode": "workflow_only"},
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
REMOTE_OCR_ENGINE="azureai",
|
|
||||||
REMOTE_OCR_API_KEY=None,
|
|
||||||
REMOTE_OCR_ENDPOINT=None,
|
|
||||||
)
|
|
||||||
def test_settings_reports_remote_ocr_incompletely_configured(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An engine named but missing its endpoint and API key
|
|
||||||
WHEN:
|
|
||||||
- The ui_settings endpoint is called
|
|
||||||
THEN:
|
|
||||||
- It is reported as not configured, matching what the parser
|
|
||||||
registry will actually do
|
|
||||||
"""
|
|
||||||
response = self.client.get(self.ENDPOINT, format="json")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertFalse(response.data["settings"]["remote_ocr"]["configured"])
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
OAUTH_CALLBACK_BASE_URL="http://localhost:8000",
|
OAUTH_CALLBACK_BASE_URL="http://localhost:8000",
|
||||||
GMAIL_OAUTH_CLIENT_ID="abc123",
|
GMAIL_OAUTH_CLIENT_ID="abc123",
|
||||||
|
|||||||
@@ -506,141 +506,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(Workflow.objects.count(), 1)
|
self.assertEqual(Workflow.objects.count(), 1)
|
||||||
|
|
||||||
def test_api_create_remote_ocr_action_requires_consumption_trigger(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- API request to create a workflow with a remote OCR action
|
|
||||||
- No consumption started trigger, so the action could never run
|
|
||||||
WHEN:
|
|
||||||
- API is called
|
|
||||||
THEN:
|
|
||||||
- Correct HTTP 400 response
|
|
||||||
- No objects are created
|
|
||||||
"""
|
|
||||||
existing_count = Workflow.objects.count()
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.ENDPOINT,
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"name": "Remote OCR too late",
|
|
||||||
"order": 1,
|
|
||||||
"triggers": [
|
|
||||||
{
|
|
||||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertEqual(Workflow.objects.count(), existing_count)
|
|
||||||
|
|
||||||
def test_api_create_remote_ocr_action_with_consumption_trigger(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- API request to create a workflow with a remote OCR action
|
|
||||||
- A consumption started trigger alongside another trigger type
|
|
||||||
WHEN:
|
|
||||||
- API is called
|
|
||||||
THEN:
|
|
||||||
- The workflow is created, the action applies to consumption only
|
|
||||||
"""
|
|
||||||
response = self.client.post(
|
|
||||||
self.ENDPOINT,
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"name": "Remote OCR on consume",
|
|
||||||
"order": 1,
|
|
||||||
"triggers": [
|
|
||||||
{
|
|
||||||
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
||||||
"filter_filename": "*.pdf",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
def test_api_partial_update_adds_remote_ocr_action(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An existing workflow with a consumption started trigger
|
|
||||||
WHEN:
|
|
||||||
- A partial update adds a remote OCR action without resubmitting triggers
|
|
||||||
THEN:
|
|
||||||
- The existing trigger is considered and the update succeeds
|
|
||||||
"""
|
|
||||||
response = self.client.patch(
|
|
||||||
f"{self.ENDPOINT}{self.workflow.id}/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(
|
|
||||||
self.workflow.actions.get().type,
|
|
||||||
WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_api_partial_update_cannot_remove_remote_ocr_trigger(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An existing workflow with a remote OCR action
|
|
||||||
- An existing consumption started trigger
|
|
||||||
WHEN:
|
|
||||||
- A partial update replaces the trigger without resubmitting actions
|
|
||||||
THEN:
|
|
||||||
- The existing action is considered and the update is rejected
|
|
||||||
"""
|
|
||||||
self.action.type = WorkflowAction.WorkflowActionType.REMOTE_OCR
|
|
||||||
self.action.save()
|
|
||||||
|
|
||||||
response = self.client.patch(
|
|
||||||
f"{self.ENDPOINT}{self.workflow.id}/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"triggers": [
|
|
||||||
{
|
|
||||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
self.assertEqual(self.workflow.triggers.get(), self.trigger)
|
|
||||||
|
|
||||||
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ from unittest import mock
|
|||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import connection
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
@@ -22,7 +19,6 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_objects
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -348,100 +344,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
assert _cf_3 is not None
|
assert _cf_3 is not None
|
||||||
self.assertNotIn(self.doc3.id, _cf_3.value)
|
self.assertNotIn(self.doc3.id, _cf_3.value)
|
||||||
|
|
||||||
def test_modify_custom_fields_batches_field_lookup(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Several documents are being bulk-edited to add several custom
|
|
||||||
fields at once
|
|
||||||
WHEN:
|
|
||||||
- modify_custom_fields runs
|
|
||||||
THEN:
|
|
||||||
- Each CustomField is resolved with one batched query total, not
|
|
||||||
once per (field, document) pair
|
|
||||||
"""
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(checksum=f"batch-{i}", title=f"batch-{i}")
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
fields = [
|
|
||||||
CustomField.objects.create(
|
|
||||||
name=f"Batch Field {i}",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
for i in range(4)
|
|
||||||
]
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
bulk_edit.modify_custom_fields(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
add_custom_fields=[field.id for field in fields],
|
|
||||||
remove_custom_fields=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
field_lookups = [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if 'FROM "documents_customfield"' in q["sql"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
len(field_lookups),
|
|
||||||
1,
|
|
||||||
"Expected a single batched query to resolve the custom fields, "
|
|
||||||
f"got {len(field_lookups)}: {field_lookups}",
|
|
||||||
)
|
|
||||||
|
|
||||||
for doc in docs:
|
|
||||||
self.assertEqual(doc.custom_fields.count(), len(fields))
|
|
||||||
|
|
||||||
def test_modify_custom_fields_batches_document_lookup_for_documentlink(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Several documents are being bulk-edited to add a DOCUMENTLINK
|
|
||||||
custom field at once
|
|
||||||
WHEN:
|
|
||||||
- modify_custom_fields runs
|
|
||||||
THEN:
|
|
||||||
- The Document rows needed to reflect the symmetrical links are
|
|
||||||
resolved with one batched query total, not once per document
|
|
||||||
"""
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(checksum=f"link-{i}", title=f"link-{i}")
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
target = Document.objects.create(checksum="link-target", title="link-target")
|
|
||||||
doclink_field = CustomField.objects.create(
|
|
||||||
name="Related",
|
|
||||||
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
bulk_edit.modify_custom_fields(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
add_custom_fields={doclink_field.id: [target.id]},
|
|
||||||
remove_custom_fields=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
single_document_lookups = [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if 'FROM "documents_document"' in q["sql"]
|
|
||||||
and '"documents_document"."id" = ' in q["sql"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
len(single_document_lookups),
|
|
||||||
0,
|
|
||||||
"Expected document rows to come from a batched query, not "
|
|
||||||
f"per-document lookups, got: {single_document_lookups}",
|
|
||||||
)
|
|
||||||
|
|
||||||
for doc in docs:
|
|
||||||
self.assertEqual(
|
|
||||||
doc.custom_fields.get(field=doclink_field).value,
|
|
||||||
[target.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_modify_custom_fields_doclink_self_link(self) -> None:
|
def test_modify_custom_fields_doclink_self_link(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -608,132 +510,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(groups_with_perms.count(), 2)
|
self.assertEqual(groups_with_perms.count(), 2)
|
||||||
|
|
||||||
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
|
||||||
def test_set_permissions_query_count_independent_of_document_count(
|
|
||||||
self,
|
|
||||||
m,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Many documents are being bulk-edited to set permissions at once
|
|
||||||
WHEN:
|
|
||||||
- set_permissions runs over a small batch vs. a much larger one
|
|
||||||
THEN:
|
|
||||||
- The number of queries issued is the same either way -- each
|
|
||||||
user/group is applied across all documents with one batched
|
|
||||||
call, not one call per (document, user) pair
|
|
||||||
"""
|
|
||||||
permissions = {
|
|
||||||
"view": {
|
|
||||||
"users": [self.user1.id, self.user2.id],
|
|
||||||
"groups": [self.group2.id],
|
|
||||||
},
|
|
||||||
"change": {
|
|
||||||
"users": [self.user1.id],
|
|
||||||
"groups": [self.group2.id],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def run_with_n_documents(n: int) -> int:
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(checksum=f"perm-{n}-{i}", title=f"perm-{n}-{i}")
|
|
||||||
for i in range(n)
|
|
||||||
]
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
bulk_edit.set_permissions(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
set_permissions=permissions,
|
|
||||||
owner=self.owner,
|
|
||||||
merge=False,
|
|
||||||
)
|
|
||||||
for doc in docs:
|
|
||||||
self.assertEqual(get_users_with_perms(doc).count(), 2)
|
|
||||||
self.assertEqual(get_groups_with_perms(doc).count(), 1)
|
|
||||||
return len(ctx.captured_queries)
|
|
||||||
|
|
||||||
small_batch_queries = run_with_n_documents(5)
|
|
||||||
large_batch_queries = run_with_n_documents(50)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
small_batch_queries,
|
|
||||||
large_batch_queries,
|
|
||||||
"Expected the same query count regardless of document count, got "
|
|
||||||
f"{small_batch_queries} queries for 5 documents vs. "
|
|
||||||
f"{large_batch_queries} for 50",
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
|
||||||
def test_set_permissions_grants_direct_perm_even_if_already_granted_via_group(
|
|
||||||
self,
|
|
||||||
m,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A user already has view access to a document via group
|
|
||||||
membership, with no direct grant of their own
|
|
||||||
WHEN:
|
|
||||||
- set_permissions explicitly grants that same user direct view
|
|
||||||
access via bulk_edit
|
|
||||||
THEN:
|
|
||||||
- A direct permission grant is created for the user, not skipped
|
|
||||||
because they already have equivalent access via the group
|
|
||||||
|
|
||||||
Regression test: guardian's queryset-aware assign_perm() (routed to
|
|
||||||
when the target is a list/queryset) skips creating a direct row for
|
|
||||||
anyone whose ObjectPermissionChecker.has_perm() already returns True
|
|
||||||
-- which includes group-derived access. The single-object assign_perm
|
|
||||||
this bulk path replaces has no such check; it always ensures a
|
|
||||||
direct row via get_or_create. Losing that guarantee would mean
|
|
||||||
revoking the group's grant later silently strips access that was
|
|
||||||
supposed to be explicit.
|
|
||||||
"""
|
|
||||||
self.doc1.owner = self.user1
|
|
||||||
self.doc1.save()
|
|
||||||
self.user1.groups.add(self.group1)
|
|
||||||
assign_perm("view_document", self.group1, self.doc1)
|
|
||||||
|
|
||||||
bulk_edit.set_permissions(
|
|
||||||
[self.doc1.id],
|
|
||||||
set_permissions={
|
|
||||||
"view": {"users": [self.user1.id], "groups": []},
|
|
||||||
},
|
|
||||||
merge=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
direct_users = get_users_with_perms(
|
|
||||||
self.doc1,
|
|
||||||
only_with_perms_in=["view_document"],
|
|
||||||
with_group_users=False,
|
|
||||||
)
|
|
||||||
self.assertIn(self.user1, direct_users)
|
|
||||||
|
|
||||||
def test_set_permissions_for_objects_raises_for_unknown_action(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An unrecognized permission action name with users to grant it
|
|
||||||
to
|
|
||||||
WHEN:
|
|
||||||
- set_permissions_for_objects is called
|
|
||||||
THEN:
|
|
||||||
- Permission.DoesNotExist is raised, not a silent no-op
|
|
||||||
|
|
||||||
Regression test: the endpoint that calls this
|
|
||||||
(BulkEditObjectPermissionsView) never actually validates action
|
|
||||||
names against the raw client-supplied permissions dict --
|
|
||||||
BulkEditObjectsSerializer._validate_permissions calls
|
|
||||||
validate_set_permissions() only for its side-effecting user/group id
|
|
||||||
checks and discards the filtered dict it returns -- so a bogus
|
|
||||||
action key reaches this function as-is. Resolving the Permission via
|
|
||||||
a bare `.filter()` (which returns empty instead of raising) would
|
|
||||||
silently drop the grant and report success.
|
|
||||||
"""
|
|
||||||
with self.assertRaises(Permission.DoesNotExist):
|
|
||||||
set_permissions_for_objects(
|
|
||||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
|
||||||
Document,
|
|
||||||
[self.doc1.pk],
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.models.Document.delete")
|
@mock.patch("documents.models.Document.delete")
|
||||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||||
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
||||||
@@ -2006,56 +1782,3 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
self.assertIn("wrong password", str(exc.exception))
|
self.assertIn("wrong password", str(exc.exception))
|
||||||
self.assertIn("Error removing password from document", cm.output[0])
|
self.assertIn("Error removing password from document", cm.output[0])
|
||||||
|
|
||||||
|
|
||||||
class TestBulkEditReprocess(DirectoriesMixin, TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
self.doc = Document.objects.create(
|
|
||||||
title="test",
|
|
||||||
checksum="A",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file")
|
|
||||||
def test_reprocess_defaults_to_local(self, mock_task: mock.Mock) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A reprocess request that says nothing about remote OCR
|
|
||||||
WHEN:
|
|
||||||
- reprocess is called
|
|
||||||
THEN:
|
|
||||||
- The task is queued without asking for the remote engine
|
|
||||||
"""
|
|
||||||
result = bulk_edit.reprocess([self.doc.id])
|
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
|
||||||
mock_task.apply_async.assert_called_once()
|
|
||||||
_, kwargs = mock_task.apply_async.call_args
|
|
||||||
self.assertEqual(
|
|
||||||
kwargs["kwargs"],
|
|
||||||
{"document_id": self.doc.id, "remote_ocr": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file")
|
|
||||||
def test_reprocess_passes_remote_ocr(self, mock_task: mock.Mock) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A reprocess request that explicitly asks for remote OCR
|
|
||||||
WHEN:
|
|
||||||
- reprocess is called
|
|
||||||
THEN:
|
|
||||||
- The request is forwarded to the task for every document
|
|
||||||
"""
|
|
||||||
other = Document.objects.create(
|
|
||||||
title="test2",
|
|
||||||
checksum="B",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
bulk_edit.reprocess([self.doc.id, other.id], remote_ocr=True)
|
|
||||||
|
|
||||||
self.assertEqual(mock_task.apply_async.call_count, 2)
|
|
||||||
for call in mock_task.apply_async.call_args_list:
|
|
||||||
self.assertTrue(call.kwargs["kwargs"]["remote_ocr"])
|
|
||||||
|
|||||||
@@ -1559,72 +1559,6 @@ class PostConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
|
|||||||
consumer.run_post_consume_script(doc)
|
consumer.run_post_consume_script(doc)
|
||||||
|
|
||||||
|
|
||||||
class TestConsumerRemoteOCR(
|
|
||||||
DirectoriesMixin,
|
|
||||||
FileSystemAssertsMixin,
|
|
||||||
GetConsumerMixin,
|
|
||||||
TestCase,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
The consumer resolves the remote OCR mode and the per-document request from
|
|
||||||
workflows into the allow_remote flag it hands to the parser registry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
patcher = mock.patch("documents.consumer.get_parser_registry")
|
|
||||||
self.mock_registry = patcher.start()
|
|
||||||
self.mock_registry.return_value.get_parser_for_file.return_value = DummyParser
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
def _consume(self, *, overrides: DocumentMetadataOverrides | None = None) -> bool:
|
|
||||||
src = (
|
|
||||||
Path(__file__).parent
|
|
||||||
/ "samples"
|
|
||||||
/ "documents"
|
|
||||||
/ "originals"
|
|
||||||
/ "0000001.pdf"
|
|
||||||
)
|
|
||||||
dst = self.dirs.scratch_dir / "sample.pdf"
|
|
||||||
shutil.copy(src, dst)
|
|
||||||
|
|
||||||
with self.get_consumer(dst, overrides=overrides) as consumer:
|
|
||||||
consumer.run()
|
|
||||||
|
|
||||||
_, kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
|
|
||||||
return kwargs["allow_remote"]
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="always")
|
|
||||||
def test_always_mode_allows_remote(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: Remote OCR mode is 'always'.
|
|
||||||
WHEN: A document is consumed without any workflow asking for it.
|
|
||||||
THEN: The registry is allowed to pick the remote parser.
|
|
||||||
"""
|
|
||||||
self.assertTrue(self._consume())
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
|
||||||
def test_workflow_only_mode_denies_remote_by_default(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: Remote OCR mode is 'workflow_only'.
|
|
||||||
WHEN: A document is consumed and nothing asked for remote OCR.
|
|
||||||
THEN: The remote parser is excluded.
|
|
||||||
"""
|
|
||||||
self.assertFalse(self._consume())
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
|
||||||
def test_workflow_only_mode_allows_remote_when_requested(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: Remote OCR mode is 'workflow_only'.
|
|
||||||
WHEN: A workflow set remote_ocr on the metadata overrides.
|
|
||||||
THEN: The registry is allowed to pick the remote parser.
|
|
||||||
"""
|
|
||||||
self.assertTrue(
|
|
||||||
self._consume(overrides=DocumentMetadataOverrides(remote_ocr=True)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMetadataOverrides(TestCase):
|
class TestMetadataOverrides(TestCase):
|
||||||
def test_update_skip_asn_if_exists(self) -> None:
|
def test_update_skip_asn_if_exists(self) -> None:
|
||||||
base = DocumentMetadataOverrides()
|
base = DocumentMetadataOverrides()
|
||||||
@@ -1632,20 +1566,6 @@ class TestMetadataOverrides(TestCase):
|
|||||||
base.update(incoming)
|
base.update(incoming)
|
||||||
self.assertTrue(base.skip_asn_if_exists)
|
self.assertTrue(base.skip_asn_if_exists)
|
||||||
|
|
||||||
def test_update_remote_ocr(self) -> None:
|
|
||||||
base = DocumentMetadataOverrides()
|
|
||||||
base.update(DocumentMetadataOverrides(remote_ocr=True))
|
|
||||||
self.assertTrue(base.remote_ocr)
|
|
||||||
|
|
||||||
def test_update_remote_ocr_is_not_unset(self) -> None:
|
|
||||||
"""
|
|
||||||
A later workflow that says nothing must not undo an earlier one that
|
|
||||||
asked for remote OCR.
|
|
||||||
"""
|
|
||||||
base = DocumentMetadataOverrides(remote_ocr=True)
|
|
||||||
base.update(DocumentMetadataOverrides())
|
|
||||||
self.assertTrue(base.remote_ocr)
|
|
||||||
|
|
||||||
def test_update_actor_and_version_label(self) -> None:
|
def test_update_actor_and_version_label(self) -> None:
|
||||||
base = DocumentMetadataOverrides(
|
base = DocumentMetadataOverrides(
|
||||||
actor_id=1,
|
actor_id=1,
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
from django.db import connection
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
|
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
|
||||||
from documents.models import CustomField
|
|
||||||
from documents.models import CustomFieldInstance
|
|
||||||
from documents.tests.factories import DocumentFactory
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentMetadataOverridesFromDocument(DirectoriesMixin, TestCase):
|
|
||||||
def test_from_document_batches_custom_field_lookup_after_refresh_from_db(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document has several custom field values
|
|
||||||
- The document instance has just been refreshed from the database,
|
|
||||||
which drops any prefetched related objects (as
|
|
||||||
send_websocket_document_updated does before building overrides)
|
|
||||||
WHEN:
|
|
||||||
- DocumentMetadataOverrides.from_document() reads the document's
|
|
||||||
custom field values
|
|
||||||
THEN:
|
|
||||||
- The referenced CustomField objects are resolved with a single
|
|
||||||
query, not one query per custom field
|
|
||||||
"""
|
|
||||||
doc = DocumentFactory(mime_type="application/pdf")
|
|
||||||
for i in range(5):
|
|
||||||
CustomFieldInstance.objects.create(
|
|
||||||
document=doc,
|
|
||||||
field=CustomField.objects.create(
|
|
||||||
name=f"Test Custom Field {i}",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
),
|
|
||||||
value_text="value",
|
|
||||||
)
|
|
||||||
|
|
||||||
doc.refresh_from_db()
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
overrides = DocumentMetadataOverrides.from_document(doc)
|
|
||||||
|
|
||||||
self.assertEqual(len(overrides.custom_fields), 5)
|
|
||||||
unbatched_field_lookups = [
|
|
||||||
query
|
|
||||||
for query in ctx.captured_queries
|
|
||||||
if 'FROM "documents_customfield" WHERE "documents_customfield"."id"'
|
|
||||||
in query["sql"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
unbatched_field_lookups,
|
|
||||||
[],
|
|
||||||
"Expected CustomField data to come from the CustomFieldInstance "
|
|
||||||
"join, not a separate per-instance lookup, "
|
|
||||||
f"got: {unbatched_field_lookups}",
|
|
||||||
)
|
|
||||||
@@ -287,45 +287,6 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
|||||||
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
|
|
||||||
"""
|
|
||||||
Consumption workflows do not run on reprocess, so the remote parser is
|
|
||||||
used only in 'always' mode or when the caller explicitly asks for it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
patcher = mock.patch("documents.tasks.get_parser_registry")
|
|
||||||
self.mock_registry = patcher.start()
|
|
||||||
self.mock_registry.return_value.get_parser_for_file.return_value = None
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
self.doc = Document.objects.create(
|
|
||||||
title="test",
|
|
||||||
content="my document",
|
|
||||||
checksum="wow",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _allow_remote(self, **kwargs) -> bool:
|
|
||||||
tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs)
|
|
||||||
_, call_kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
|
|
||||||
return call_kwargs["allow_remote"]
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="always")
|
|
||||||
def test_always_mode_allows_remote(self) -> None:
|
|
||||||
self.assertTrue(self._allow_remote())
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
|
||||||
def test_workflow_only_mode_denies_remote_by_default(self) -> None:
|
|
||||||
self.assertFalse(self._allow_remote())
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
|
||||||
def test_workflow_only_mode_allows_remote_when_requested(self) -> None:
|
|
||||||
self.assertTrue(self._allow_remote(remote_ocr=True))
|
|
||||||
|
|
||||||
|
|
||||||
class TestAIIndex(DirectoriesMixin, TestCase):
|
class TestAIIndex(DirectoriesMixin, TestCase):
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
|
|||||||
@@ -5409,82 +5409,3 @@ class TestDateWorkflowLocalization(
|
|||||||
document = Document.objects.first()
|
document = Document.objects.first()
|
||||||
assert document is not None
|
assert document is not None
|
||||||
assert document.title == expected_title
|
assert document.title == expected_title
|
||||||
|
|
||||||
|
|
||||||
class TestRemoteOCRWorkflowAction(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|
||||||
def _make_workflow(self, trigger_type) -> None:
|
|
||||||
trigger = WorkflowTrigger.objects.create(type=trigger_type)
|
|
||||||
action = WorkflowAction.objects.create(
|
|
||||||
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
||||||
)
|
|
||||||
w = Workflow.objects.create(name="Remote OCR", order=0)
|
|
||||||
w.triggers.add(trigger)
|
|
||||||
w.actions.add(action)
|
|
||||||
w.save()
|
|
||||||
|
|
||||||
def test_consumption_trigger_requests_remote_ocr(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A consumption workflow with a remote OCR action
|
|
||||||
WHEN:
|
|
||||||
- A matching document is consumed
|
|
||||||
THEN:
|
|
||||||
- The overrides ask for remote OCR, which is what the consumer
|
|
||||||
reads when choosing a parser
|
|
||||||
"""
|
|
||||||
self._make_workflow(WorkflowTrigger.WorkflowTriggerType.CONSUMPTION)
|
|
||||||
|
|
||||||
test_file = shutil.copy(
|
|
||||||
self.SAMPLE_DIR / "simple.pdf",
|
|
||||||
self.dirs.scratch_dir / "simple.pdf",
|
|
||||||
)
|
|
||||||
overrides = DocumentMetadataOverrides()
|
|
||||||
|
|
||||||
run_workflows(
|
|
||||||
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
||||||
ConsumableDocument(
|
|
||||||
source=DocumentSource.ConsumeFolder,
|
|
||||||
original_file=test_file,
|
|
||||||
),
|
|
||||||
overrides=overrides,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(overrides.remote_ocr)
|
|
||||||
|
|
||||||
def test_other_trigger_types_are_ignored(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A workflow with a remote OCR action that also has a
|
|
||||||
non-consumption trigger, which is a valid combination
|
|
||||||
WHEN:
|
|
||||||
- The non-consumption trigger fires
|
|
||||||
THEN:
|
|
||||||
- The action is skipped, since the document has already been
|
|
||||||
parsed by this point
|
|
||||||
"""
|
|
||||||
trigger = WorkflowTrigger.objects.create(
|
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
||||||
)
|
|
||||||
updated_trigger = WorkflowTrigger.objects.create(
|
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
||||||
)
|
|
||||||
action = WorkflowAction.objects.create(
|
|
||||||
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
||||||
)
|
|
||||||
w = Workflow.objects.create(name="Remote OCR", order=0)
|
|
||||||
w.triggers.add(trigger, updated_trigger)
|
|
||||||
w.actions.add(action)
|
|
||||||
w.save()
|
|
||||||
|
|
||||||
doc = Document.objects.create(
|
|
||||||
title="sample test",
|
|
||||||
original_filename="sample.pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
with self.assertLogs("paperless.handlers", level="DEBUG") as cm:
|
|
||||||
run_workflows(
|
|
||||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
||||||
doc,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertIn("only applies to consumption triggers", "".join(cm.output))
|
|
||||||
|
|||||||
+67
-39
@@ -16,6 +16,7 @@ from time import mktime
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import Final
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from unicodedata import normalize
|
from unicodedata import normalize
|
||||||
@@ -178,7 +179,7 @@ from documents.permissions import has_perms_owner_aware
|
|||||||
from documents.permissions import has_system_status_permission
|
from documents.permissions import has_system_status_permission
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
from documents.schema import generate_object_with_permissions_schema
|
from documents.schema import generate_object_with_permissions_schema
|
||||||
from documents.search import SearchHit
|
from documents.search import SearchHit
|
||||||
@@ -239,10 +240,8 @@ from paperless import version
|
|||||||
from paperless.celery import app as celery_app
|
from paperless.celery import app as celery_app
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless.config import GeneralConfig
|
from paperless.config import GeneralConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.parsers.registry import get_parser_registry
|
from paperless.parsers.registry import get_parser_registry
|
||||||
from paperless.parsers.remote import RemoteEngineConfig
|
|
||||||
from paperless.serialisers import GroupSerializer
|
from paperless.serialisers import GroupSerializer
|
||||||
from paperless.serialisers import UserSerializer
|
from paperless.serialisers import UserSerializer
|
||||||
from paperless.views import StandardPagination
|
from paperless.views import StandardPagination
|
||||||
@@ -281,17 +280,40 @@ logger = logging.getLogger("paperless.api")
|
|||||||
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
_TANTIVY_INTERSECT_THRESHOLD = 5_000
|
||||||
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
_TANTIVY_SEARCH_PARAM_NAMES = ("text", "title_search", "query", "more_like_id")
|
||||||
|
|
||||||
|
# whoosh-compat's fieldname tagger (used only for SearchMode.QUERY, via the
|
||||||
|
# whoosh grammar in parse_user_query) is O(n^2) in plain word characters:
|
||||||
|
# measured at ~0.96s/10k chars, ~3.67s/20k, ~14.4s/40k against the real field
|
||||||
|
# registry. Django's DATA_UPLOAD_MAX_MEMORY_SIZE default (2.5 MB) does not
|
||||||
|
# bound this on the POST-body selection-filter path, so an unbounded query
|
||||||
|
# is a single-request CPU exhaustion vector. 4096 chars caps the worst case
|
||||||
|
# at roughly 0.16s (quadratic extrapolation from the measurements above),
|
||||||
|
# far beyond any plausible hand-typed advanced query, while still being fast
|
||||||
|
# enough to absorb inside a request handler. Applied to all three modes at
|
||||||
|
# this shared choke point: TEXT and TITLE route through simple_search_tokens
|
||||||
|
# instead and measure linear even at 20k chars, so the cap is hygiene for
|
||||||
|
# them, not a fix, but a single limit here is simpler than one exemption.
|
||||||
|
# Not exposed as a PAPERLESS_* setting: this is a hard security boundary,
|
||||||
|
# not a tunable, and a raisable ceiling would let a misconfiguration
|
||||||
|
# reintroduce the exact hazard this exists to close.
|
||||||
|
_MAX_QUERY_LENGTH: Final[int] = 4096
|
||||||
|
|
||||||
|
|
||||||
def _get_tantivy_query_and_mode(params):
|
def _get_tantivy_query_and_mode(params):
|
||||||
|
from documents.search import QueryTooLongError
|
||||||
from documents.search import SearchMode
|
from documents.search import SearchMode
|
||||||
|
|
||||||
if "text" in params:
|
if "text" in params:
|
||||||
return str(params["text"]), SearchMode.TEXT
|
raw, mode = str(params["text"]), SearchMode.TEXT
|
||||||
if "title_search" in params:
|
elif "title_search" in params:
|
||||||
return str(params["title_search"]), SearchMode.TITLE
|
raw, mode = str(params["title_search"]), SearchMode.TITLE
|
||||||
if "query" in params:
|
elif "query" in params:
|
||||||
return str(params["query"]), SearchMode.QUERY
|
raw, mode = str(params["query"]), SearchMode.QUERY
|
||||||
return None # pragma: no cover
|
else:
|
||||||
|
return None # pragma: no cover
|
||||||
|
|
||||||
|
if len(raw) > _MAX_QUERY_LENGTH:
|
||||||
|
raise QueryTooLongError(len(raw), _MAX_QUERY_LENGTH)
|
||||||
|
return raw, mode
|
||||||
|
|
||||||
|
|
||||||
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
|
||||||
@@ -2423,6 +2445,7 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
from documents.search import TantivyBackend
|
from documents.search import TantivyBackend
|
||||||
from documents.search import TantivyRelevanceList
|
from documents.search import TantivyRelevanceList
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
from documents.search import search_query_error_messages
|
||||||
|
|
||||||
def parse_search_params() -> SearchParams:
|
def parse_search_params() -> SearchParams:
|
||||||
"""Extract query string, search mode, and ordering from request."""
|
"""Extract query string, search mode, and ordering from request."""
|
||||||
@@ -2613,15 +2636,10 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
|||||||
except ValidationError:
|
except ValidationError:
|
||||||
raise
|
raise
|
||||||
except SearchQueryError as e:
|
except SearchQueryError as e:
|
||||||
# User-fixable query error (e.g. an unparsable date): surface the
|
# User-fixable query error(s) (e.g. unparsable dates/numbers):
|
||||||
# specific message so the user can correct it, rather than a generic
|
# surface every offending field's message, not just the first,
|
||||||
# 400 or silently empty results.
|
# so the user can fix them all in one round-trip.
|
||||||
raise ValidationError({"query": [str(e)]}) from e
|
raise ValidationError({"query": search_query_error_messages(e)}) from e
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"An error occurred listing search results: {e!s}")
|
|
||||||
return HttpResponseBadRequest(
|
|
||||||
"Error listing search results, check logs for more detail.",
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["GET"], name="Get Next ASN")
|
@action(detail=False, methods=["GET"], name="Get Next ASN")
|
||||||
def next_asn(self, request, *args, **kwargs):
|
def next_asn(self, request, *args, **kwargs):
|
||||||
@@ -2757,23 +2775,34 @@ class DocumentSelectionMixin:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from documents.search import SearchQueryError
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
from documents.search import search_query_error_messages
|
||||||
|
|
||||||
filter_name = search_filters[0]
|
filter_name = search_filters[0]
|
||||||
backend = get_backend()
|
backend = get_backend()
|
||||||
search_user = None if user.is_superuser else user
|
search_user = None if user.is_superuser else user
|
||||||
|
|
||||||
if filter_name == "more_like_id":
|
try:
|
||||||
more_like_doc_id = _get_more_like_id(filters, user)
|
if filter_name == "more_like_id":
|
||||||
|
more_like_doc_id = _get_more_like_id(filters, user)
|
||||||
|
|
||||||
search_ids = backend.more_like_this_ids(more_like_doc_id, user=search_user)
|
search_ids = backend.more_like_this_ids(
|
||||||
else:
|
more_like_doc_id,
|
||||||
query_str, search_mode = _get_tantivy_query_and_mode(filters)
|
user=search_user,
|
||||||
search_ids = backend.search_ids(
|
)
|
||||||
query_str,
|
else:
|
||||||
user=search_user,
|
query_str, search_mode = _get_tantivy_query_and_mode(filters)
|
||||||
search_mode=search_mode,
|
search_ids = backend.search_ids(
|
||||||
)
|
query_str,
|
||||||
|
user=search_user,
|
||||||
|
search_mode=search_mode,
|
||||||
|
)
|
||||||
|
except SearchQueryError as e:
|
||||||
|
# Same user-fixable-query mapping as the search list endpoint:
|
||||||
|
# a bad date/number in a bulk selection filter is a 400 naming
|
||||||
|
# the value, never a 500.
|
||||||
|
raise ValidationError({"query": search_query_error_messages(e)}) from e
|
||||||
|
|
||||||
return search_ids
|
return search_ids
|
||||||
|
|
||||||
@@ -3610,6 +3639,10 @@ class GlobalSearchView(PassUserMixin):
|
|||||||
return HttpResponseBadRequest("Query required")
|
return HttpResponseBadRequest("Query required")
|
||||||
if len(query) < 3:
|
if len(query) < 3:
|
||||||
return HttpResponseBadRequest("Query must be at least 3 characters")
|
return HttpResponseBadRequest("Query must be at least 3 characters")
|
||||||
|
if len(query) > _MAX_QUERY_LENGTH:
|
||||||
|
return HttpResponseBadRequest(
|
||||||
|
f"Query must be at most {_MAX_QUERY_LENGTH} characters",
|
||||||
|
)
|
||||||
|
|
||||||
db_only = request.query_params.get("db_only", False)
|
db_only = request.query_params.get("db_only", False)
|
||||||
|
|
||||||
@@ -4102,11 +4135,6 @@ class UiSettingsView(GenericAPIView[Any]):
|
|||||||
|
|
||||||
ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED
|
ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED
|
||||||
|
|
||||||
ui_settings["remote_ocr"] = {
|
|
||||||
"configured": RemoteEngineConfig.from_app_config().engine_is_valid(),
|
|
||||||
"mode": RemoteOCRConfig().remote_ocr_mode,
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.GMAIL_OAUTH_ENABLED or settings.OUTLOOK_OAUTH_ENABLED:
|
if settings.GMAIL_OAUTH_ENABLED or settings.OUTLOOK_OAUTH_ENABLED:
|
||||||
manager = PaperlessMailOAuth2Manager()
|
manager = PaperlessMailOAuth2Manager()
|
||||||
if settings.GMAIL_OAUTH_ENABLED:
|
if settings.GMAIL_OAUTH_ENABLED:
|
||||||
@@ -4917,12 +4945,12 @@ class BulkEditObjectsView(PassUserMixin):
|
|||||||
qs_owner_update.update(owner=owner)
|
qs_owner_update.update(owner=owner)
|
||||||
|
|
||||||
if "permissions" in serializer.validated_data:
|
if "permissions" in serializer.validated_data:
|
||||||
set_permissions_for_objects(
|
for obj in qs:
|
||||||
permissions=permissions,
|
set_permissions_for_object(
|
||||||
model=object_class,
|
permissions=permissions,
|
||||||
pks=qs.values_list("pk", flat=True),
|
object=obj,
|
||||||
merge=merge,
|
merge=merge,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-08-24 21:43+0000\n"
|
"POT-Creation-Date: 2026-08-19 18:18+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -89,7 +89,7 @@ msgstr ""
|
|||||||
msgid "Automatic"
|
msgid "Automatic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:65 documents/models.py:606 documents/models.py:1924
|
#: documents/models.py:65 documents/models.py:606 documents/models.py:1920
|
||||||
#: paperless_mail/models.py:23 paperless_mail/models.py:143
|
#: paperless_mail/models.py:23 paperless_mail/models.py:143
|
||||||
msgid "name"
|
msgid "name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -272,7 +272,7 @@ msgid "Optional short label for a document version."
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:340 documents/models.py:922 documents/models.py:976
|
#: documents/models.py:340 documents/models.py:922 documents/models.py:976
|
||||||
#: documents/models.py:1967
|
#: documents/models.py:1963
|
||||||
msgid "document"
|
msgid "document"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -1385,202 +1385,198 @@ msgid "Move to trash"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1675
|
#: documents/models.py:1675
|
||||||
msgid "Remote OCR"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: documents/models.py:1679
|
|
||||||
msgid "Workflow Action Type"
|
msgid "Workflow Action Type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1684 documents/models.py:1926
|
#: documents/models.py:1680 documents/models.py:1922
|
||||||
#: paperless_mail/models.py:145
|
#: paperless_mail/models.py:145
|
||||||
msgid "order"
|
msgid "order"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1687
|
#: documents/models.py:1683
|
||||||
msgid "assign title"
|
msgid "assign title"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1691
|
#: documents/models.py:1687
|
||||||
msgid "Assign a document title, must be a Jinja2 template, see documentation."
|
msgid "Assign a document title, must be a Jinja2 template, see documentation."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1699 paperless_mail/models.py:274
|
#: documents/models.py:1695 paperless_mail/models.py:274
|
||||||
msgid "assign this tag"
|
msgid "assign this tag"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1708 paperless_mail/models.py:282
|
#: documents/models.py:1704 paperless_mail/models.py:282
|
||||||
msgid "assign this document type"
|
msgid "assign this document type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1717 paperless_mail/models.py:296
|
#: documents/models.py:1713 paperless_mail/models.py:296
|
||||||
msgid "assign this correspondent"
|
msgid "assign this correspondent"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1726
|
#: documents/models.py:1722
|
||||||
msgid "assign this storage path"
|
msgid "assign this storage path"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1735
|
#: documents/models.py:1731
|
||||||
msgid "assign this owner"
|
msgid "assign this owner"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1742
|
#: documents/models.py:1738
|
||||||
msgid "grant view permissions to these users"
|
msgid "grant view permissions to these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1749
|
#: documents/models.py:1745
|
||||||
msgid "grant view permissions to these groups"
|
msgid "grant view permissions to these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1756
|
#: documents/models.py:1752
|
||||||
msgid "grant change permissions to these users"
|
msgid "grant change permissions to these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1763
|
#: documents/models.py:1759
|
||||||
msgid "grant change permissions to these groups"
|
msgid "grant change permissions to these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1770
|
#: documents/models.py:1766
|
||||||
msgid "assign these custom fields"
|
msgid "assign these custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1774
|
#: documents/models.py:1770
|
||||||
msgid "custom field values"
|
msgid "custom field values"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1778
|
#: documents/models.py:1774
|
||||||
msgid "Optional values to assign to the custom fields."
|
msgid "Optional values to assign to the custom fields."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1787
|
#: documents/models.py:1783
|
||||||
msgid "remove these tag(s)"
|
msgid "remove these tag(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1792
|
#: documents/models.py:1788
|
||||||
msgid "remove all tags"
|
msgid "remove all tags"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1799
|
#: documents/models.py:1795
|
||||||
msgid "remove these document type(s)"
|
msgid "remove these document type(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1804
|
#: documents/models.py:1800
|
||||||
msgid "remove all document types"
|
msgid "remove all document types"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1811
|
#: documents/models.py:1807
|
||||||
msgid "remove these correspondent(s)"
|
msgid "remove these correspondent(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1816
|
#: documents/models.py:1812
|
||||||
msgid "remove all correspondents"
|
msgid "remove all correspondents"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1823
|
#: documents/models.py:1819
|
||||||
msgid "remove these storage path(s)"
|
msgid "remove these storage path(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1828
|
#: documents/models.py:1824
|
||||||
msgid "remove all storage paths"
|
msgid "remove all storage paths"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1835
|
#: documents/models.py:1831
|
||||||
msgid "remove these owner(s)"
|
msgid "remove these owner(s)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1840
|
#: documents/models.py:1836
|
||||||
msgid "remove all owners"
|
msgid "remove all owners"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1847
|
#: documents/models.py:1843
|
||||||
msgid "remove view permissions for these users"
|
msgid "remove view permissions for these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1854
|
#: documents/models.py:1850
|
||||||
msgid "remove view permissions for these groups"
|
msgid "remove view permissions for these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1861
|
#: documents/models.py:1857
|
||||||
msgid "remove change permissions for these users"
|
msgid "remove change permissions for these users"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1868
|
#: documents/models.py:1864
|
||||||
msgid "remove change permissions for these groups"
|
msgid "remove change permissions for these groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1873
|
#: documents/models.py:1869
|
||||||
msgid "remove all permissions"
|
msgid "remove all permissions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1880
|
#: documents/models.py:1876
|
||||||
msgid "remove these custom fields"
|
msgid "remove these custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1885
|
#: documents/models.py:1881
|
||||||
msgid "remove all custom fields"
|
msgid "remove all custom fields"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1894
|
#: documents/models.py:1890
|
||||||
msgid "email"
|
msgid "email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1903
|
#: documents/models.py:1899
|
||||||
msgid "webhook"
|
msgid "webhook"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1907
|
#: documents/models.py:1903
|
||||||
msgid "passwords"
|
msgid "passwords"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1911
|
#: documents/models.py:1907
|
||||||
msgid ""
|
msgid ""
|
||||||
"Passwords to try when removing PDF protection. Separate with commas or new "
|
"Passwords to try when removing PDF protection. Separate with commas or new "
|
||||||
"lines."
|
"lines."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1916
|
#: documents/models.py:1912
|
||||||
msgid "workflow action"
|
msgid "workflow action"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1917
|
#: documents/models.py:1913
|
||||||
msgid "workflow actions"
|
msgid "workflow actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1932
|
#: documents/models.py:1928
|
||||||
msgid "triggers"
|
msgid "triggers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1939
|
#: documents/models.py:1935
|
||||||
msgid "actions"
|
msgid "actions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1942 paperless_mail/models.py:154
|
#: documents/models.py:1938 paperless_mail/models.py:154
|
||||||
msgid "enabled"
|
msgid "enabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1953
|
#: documents/models.py:1949
|
||||||
msgid "workflow"
|
msgid "workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1957
|
#: documents/models.py:1953
|
||||||
msgid "workflow trigger type"
|
msgid "workflow trigger type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1971
|
#: documents/models.py:1967
|
||||||
msgid "date run"
|
msgid "date run"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1977
|
#: documents/models.py:1973
|
||||||
msgid "workflow run"
|
msgid "workflow run"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/models.py:1978
|
#: documents/models.py:1974
|
||||||
msgid "workflow runs"
|
msgid "workflow runs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:523 documents/serialisers.py:875
|
#: documents/serialisers.py:523 documents/serialisers.py:875
|
||||||
#: documents/serialisers.py:2827 documents/views.py:311 documents/views.py:2612
|
#: documents/serialisers.py:2818 documents/views.py:309 documents/views.py:2610
|
||||||
#: paperless_mail/serialisers.py:155
|
#: paperless_mail/serialisers.py:155
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1589,39 +1585,39 @@ msgstr ""
|
|||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2304
|
#: documents/serialisers.py:2295
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2348
|
#: documents/serialisers.py:2339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2355
|
#: documents/serialisers.py:2346
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2372 documents/serialisers.py:2382
|
#: documents/serialisers.py:2363 documents/serialisers.py:2373
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2377
|
#: documents/serialisers.py:2368
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2524
|
#: documents/serialisers.py:2515
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2883
|
#: documents/serialisers.py:2874
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2913 documents/views.py:4606
|
#: documents/serialisers.py:2904 documents/views.py:4599
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1889,36 +1885,36 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:304 documents/views.py:2609
|
#: documents/views.py:302 documents/views.py:2607
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1586
|
#: documents/views.py:1584
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1597
|
#: documents/views.py:1595
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2434 documents/views.py:2755
|
#: documents/views.py:2432 documents/views.py:2753
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4619
|
#: documents/views.py:4612
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4665
|
#: documents/views.py:4658
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4726
|
#: documents/views.py:4719
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4736
|
#: documents/views.py:4729
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -2002,203 +1998,175 @@ msgstr ""
|
|||||||
msgid "CMYK"
|
msgid "CMYK"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:82
|
#: paperless/models.py:78 paperless/models.py:88
|
||||||
msgid "Azure AI Document Intelligence"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: paperless/models.py:90
|
|
||||||
msgid "All supported documents"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: paperless/models.py:91
|
|
||||||
msgid "Only when a workflow enables it"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: paperless/models.py:95 paperless/models.py:105
|
|
||||||
msgid "OpenAI-compatible"
|
msgid "OpenAI-compatible"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:96
|
#: paperless/models.py:79
|
||||||
msgid "Huggingface"
|
msgid "Huggingface"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:97 paperless/models.py:106
|
#: paperless/models.py:80 paperless/models.py:89
|
||||||
msgid "Ollama"
|
msgid "Ollama"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:115
|
#: paperless/models.py:98
|
||||||
msgid "Sets the output PDF type"
|
msgid "Sets the output PDF type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:127
|
#: paperless/models.py:110
|
||||||
msgid "Do OCR from page 1 to this value"
|
msgid "Do OCR from page 1 to this value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:133
|
#: paperless/models.py:116
|
||||||
msgid "Do OCR using these languages"
|
msgid "Do OCR using these languages"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:140
|
#: paperless/models.py:123
|
||||||
msgid "Sets the OCR mode"
|
msgid "Sets the OCR mode"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:148
|
#: paperless/models.py:131
|
||||||
msgid "Controls archive file generation"
|
msgid "Controls archive file generation"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:156
|
#: paperless/models.py:139
|
||||||
msgid "Sets image DPI fallback value"
|
msgid "Sets image DPI fallback value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:163
|
#: paperless/models.py:146
|
||||||
msgid "Controls the unpaper cleaning"
|
msgid "Controls the unpaper cleaning"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:170
|
#: paperless/models.py:153
|
||||||
msgid "Enables deskew"
|
msgid "Enables deskew"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:173
|
#: paperless/models.py:156
|
||||||
msgid "Enables page rotation"
|
msgid "Enables page rotation"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:178
|
#: paperless/models.py:161
|
||||||
msgid "Sets the threshold for rotation of pages"
|
msgid "Sets the threshold for rotation of pages"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:184
|
#: paperless/models.py:167
|
||||||
msgid "Sets the maximum image size for decompression"
|
msgid "Sets the maximum image size for decompression"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:190
|
#: paperless/models.py:173
|
||||||
msgid "Sets the Ghostscript color conversion strategy"
|
msgid "Sets the Ghostscript color conversion strategy"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:198
|
#: paperless/models.py:181
|
||||||
msgid "Adds additional user arguments for OCRMyPDF"
|
msgid "Adds additional user arguments for OCRMyPDF"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:207
|
#: paperless/models.py:190
|
||||||
msgid "Application title"
|
msgid "Application title"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:214
|
#: paperless/models.py:197
|
||||||
msgid "Application logo"
|
msgid "Application logo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:229
|
#: paperless/models.py:212
|
||||||
msgid "Enables barcode scanning"
|
msgid "Enables barcode scanning"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:235
|
#: paperless/models.py:218
|
||||||
msgid "Enables barcode TIFF support"
|
msgid "Enables barcode TIFF support"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:241
|
#: paperless/models.py:224
|
||||||
msgid "Sets the barcode string"
|
msgid "Sets the barcode string"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:249
|
#: paperless/models.py:232
|
||||||
msgid "Retains split pages"
|
msgid "Retains split pages"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:255
|
#: paperless/models.py:238
|
||||||
msgid "Enables ASN barcode"
|
msgid "Enables ASN barcode"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:261
|
#: paperless/models.py:244
|
||||||
msgid "Sets the ASN barcode prefix"
|
msgid "Sets the ASN barcode prefix"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:269
|
#: paperless/models.py:252
|
||||||
msgid "Sets the barcode upscale factor"
|
msgid "Sets the barcode upscale factor"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:276
|
#: paperless/models.py:259
|
||||||
msgid "Sets the barcode DPI"
|
msgid "Sets the barcode DPI"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:283
|
#: paperless/models.py:266
|
||||||
msgid "Sets the maximum pages for barcode"
|
msgid "Sets the maximum pages for barcode"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:290
|
#: paperless/models.py:273
|
||||||
msgid "Enables tag barcode"
|
msgid "Enables tag barcode"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:296
|
#: paperless/models.py:279
|
||||||
msgid "Sets the tag barcode mapping"
|
msgid "Sets the tag barcode mapping"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:302
|
#: paperless/models.py:285
|
||||||
msgid "Enables splitting on tag barcodes"
|
msgid "Enables splitting on tag barcodes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:312
|
#: paperless/models.py:294
|
||||||
msgid "Sets the remote OCR engine"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: paperless/models.py:321
|
|
||||||
msgid "Sets the remote OCR API key"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: paperless/models.py:329
|
|
||||||
msgid "Sets the remote OCR endpoint"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: paperless/models.py:337
|
|
||||||
msgid "Sets which documents are sent to the remote OCR engine"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: paperless/models.py:349
|
|
||||||
msgid "Enables AI features"
|
msgid "Enables AI features"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:355
|
#: paperless/models.py:300
|
||||||
msgid "Sets the LLM embedding backend"
|
msgid "Sets the LLM embedding backend"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:363
|
#: paperless/models.py:308
|
||||||
msgid "Sets the LLM embedding model"
|
msgid "Sets the LLM embedding model"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:370
|
#: paperless/models.py:315
|
||||||
msgid "Sets the LLM embedding endpoint, optional"
|
msgid "Sets the LLM embedding endpoint, optional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:377
|
#: paperless/models.py:322
|
||||||
msgid "Sets the LLM embedding chunk size"
|
msgid "Sets the LLM embedding chunk size"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:383
|
#: paperless/models.py:328
|
||||||
msgid "Sets the LLM context size"
|
msgid "Sets the LLM context size"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:389
|
#: paperless/models.py:334
|
||||||
msgid "Sets the LLM backend"
|
msgid "Sets the LLM backend"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:397
|
#: paperless/models.py:342
|
||||||
msgid "Sets the LLM model"
|
msgid "Sets the LLM model"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:404
|
#: paperless/models.py:349
|
||||||
msgid "Sets the LLM API key"
|
msgid "Sets the LLM API key"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:411
|
#: paperless/models.py:356
|
||||||
msgid "Sets the LLM endpoint, optional"
|
msgid "Sets the LLM endpoint, optional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:418
|
#: paperless/models.py:363
|
||||||
msgid "Sets the LLM output language"
|
msgid "Sets the LLM output language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:425
|
#: paperless/models.py:370
|
||||||
msgid "Sets the LLM timeout in seconds"
|
msgid "Sets the LLM timeout in seconds"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/models.py:431
|
#: paperless/models.py:376
|
||||||
msgid "paperless application settings"
|
msgid "paperless application settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,20 @@ def check_deprecated_v2_ocr_env_vars(
|
|||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
@register()
|
||||||
|
def check_remote_parser_configured(app_configs: Any, **kwargs: Any) -> list[Error]:
|
||||||
|
if settings.REMOTE_OCR_ENGINE == "azureai" and not (
|
||||||
|
settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY
|
||||||
|
):
|
||||||
|
return [
|
||||||
|
Error(
|
||||||
|
"Azure AI remote parser requires endpoint and API key to be configured.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_tesseract_langs():
|
def get_tesseract_langs():
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[shutil.which("tesseract"), "--list-langs"],
|
[shutil.which("tesseract"), "--list-langs"],
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from paperless.models import CleanChoices
|
|||||||
from paperless.models import ColorConvertChoices
|
from paperless.models import ColorConvertChoices
|
||||||
from paperless.models import ModeChoices
|
from paperless.models import ModeChoices
|
||||||
from paperless.models import OutputTypeChoices
|
from paperless.models import OutputTypeChoices
|
||||||
from paperless.models import RemoteOCRMode
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -186,45 +185,6 @@ class GeneralConfig(BaseConfig):
|
|||||||
self.app_logo = app_config.app_logo.url if app_config.app_logo else None
|
self.app_logo = app_config.app_logo.url if app_config.app_logo else None
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class RemoteOCRConfig(BaseConfig):
|
|
||||||
"""
|
|
||||||
Settings for the remote (cloud) OCR parser
|
|
||||||
"""
|
|
||||||
|
|
||||||
remote_ocr_engine: str | None = dataclasses.field(init=False)
|
|
||||||
remote_ocr_api_key: str | None = dataclasses.field(init=False)
|
|
||||||
remote_ocr_endpoint: str | None = dataclasses.field(init=False)
|
|
||||||
remote_ocr_mode: RemoteOCRMode = dataclasses.field(init=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
app_config = self._get_config_instance()
|
|
||||||
|
|
||||||
self.remote_ocr_engine = (
|
|
||||||
app_config.remote_ocr_engine or settings.REMOTE_OCR_ENGINE
|
|
||||||
)
|
|
||||||
self.remote_ocr_api_key = (
|
|
||||||
app_config.remote_ocr_api_key or settings.REMOTE_OCR_API_KEY
|
|
||||||
)
|
|
||||||
self.remote_ocr_endpoint = (
|
|
||||||
app_config.remote_ocr_endpoint or settings.REMOTE_OCR_ENDPOINT
|
|
||||||
)
|
|
||||||
self.remote_ocr_mode = app_config.remote_ocr_mode or RemoteOCRMode(
|
|
||||||
settings.REMOTE_OCR_MODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def remote_ocr_by_default(self) -> bool:
|
|
||||||
"""
|
|
||||||
Whether every supported document goes to the remote engine.
|
|
||||||
|
|
||||||
When False the remote engine is used only for documents that
|
|
||||||
explicitly asked for it, i.e. a workflow matched during consumption or
|
|
||||||
the user ticked the box when reprocessing.
|
|
||||||
"""
|
|
||||||
return self.remote_ocr_mode == RemoteOCRMode.ALWAYS
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class AIConfig(BaseConfig):
|
class AIConfig(BaseConfig):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# Generated by Django 5.2.16 on 2026-08-10 14:37
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("paperless", "0013_applicationconfiguration_llm_request_timeout"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="applicationconfiguration",
|
|
||||||
name="remote_ocr_api_key",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
max_length=1024,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Sets the remote OCR API key",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="applicationconfiguration",
|
|
||||||
name="remote_ocr_endpoint",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
max_length=256,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Sets the remote OCR endpoint",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="applicationconfiguration",
|
|
||||||
name="remote_ocr_engine",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
choices=[("azureai", "Azure AI Document Intelligence")],
|
|
||||||
max_length=32,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Sets the remote OCR engine",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Generated by Django 5.2.16 on 2026-08-10 15:43
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("paperless", "0014_applicationconfiguration_remote_ocr_api_key_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="applicationconfiguration",
|
|
||||||
name="remote_ocr_mode",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
choices=[
|
|
||||||
("always", "All supported documents"),
|
|
||||||
("workflow_only", "Only when a workflow enables it"),
|
|
||||||
],
|
|
||||||
max_length=32,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Sets which documents are sent to the remote OCR engine",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -74,23 +74,6 @@ class ColorConvertChoices(models.TextChoices):
|
|||||||
CMYK = ("CMYK", _("CMYK"))
|
CMYK = ("CMYK", _("CMYK"))
|
||||||
|
|
||||||
|
|
||||||
class RemoteOCREngine(models.TextChoices):
|
|
||||||
"""
|
|
||||||
Matches to PAPERLESS_REMOTE_OCR_ENGINE
|
|
||||||
"""
|
|
||||||
|
|
||||||
AZURE_AI = ("azureai", _("Azure AI Document Intelligence"))
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteOCRMode(models.TextChoices):
|
|
||||||
"""
|
|
||||||
Matches to PAPERLESS_REMOTE_OCR_MODE
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALWAYS = ("always", _("All supported documents"))
|
|
||||||
WORKFLOW_ONLY = ("workflow_only", _("Only when a workflow enables it"))
|
|
||||||
|
|
||||||
|
|
||||||
class LLMEmbeddingBackend(models.TextChoices):
|
class LLMEmbeddingBackend(models.TextChoices):
|
||||||
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
||||||
HUGGINGFACE = ("huggingface", _("Huggingface"))
|
HUGGINGFACE = ("huggingface", _("Huggingface"))
|
||||||
@@ -303,44 +286,6 @@ class ApplicationConfiguration(AbstractSingletonModel):
|
|||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
"""
|
|
||||||
Settings for the remote OCR parser
|
|
||||||
"""
|
|
||||||
|
|
||||||
# PAPERLESS_REMOTE_OCR_ENGINE
|
|
||||||
remote_ocr_engine = models.CharField(
|
|
||||||
verbose_name=_("Sets the remote OCR engine"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
max_length=32,
|
|
||||||
choices=RemoteOCREngine.choices,
|
|
||||||
)
|
|
||||||
|
|
||||||
# PAPERLESS_REMOTE_OCR_API_KEY
|
|
||||||
remote_ocr_api_key = models.CharField(
|
|
||||||
verbose_name=_("Sets the remote OCR API key"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
max_length=1024,
|
|
||||||
)
|
|
||||||
|
|
||||||
# PAPERLESS_REMOTE_OCR_ENDPOINT
|
|
||||||
remote_ocr_endpoint = models.CharField(
|
|
||||||
verbose_name=_("Sets the remote OCR endpoint"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
max_length=256,
|
|
||||||
)
|
|
||||||
|
|
||||||
# PAPERLESS_REMOTE_OCR_MODE
|
|
||||||
remote_ocr_mode = models.CharField(
|
|
||||||
verbose_name=_("Sets which documents are sent to the remote OCR engine"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
max_length=32,
|
|
||||||
choices=RemoteOCRMode.choices,
|
|
||||||
)
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
AI related settings
|
AI related settings
|
||||||
"""
|
"""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user