mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-09 19:27:59 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
310628699d | ||
|
|
aff0f9cf41 | ||
|
|
bf716ebfd1 | ||
|
|
7d67a10a35 | ||
|
|
8d1bc5dd24 | ||
|
|
43a8d7d412 | ||
|
|
c40922440b |
@@ -112,22 +112,6 @@
|
||||
|
||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
||||
|
||||
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
|
||||
@for (option of sidebarItemOptions; track option.id) {
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[id]="'sidebar-item-setting-' + option.id"
|
||||
[checked]="isSidebarItemShown(option.id)"
|
||||
(change)="toggleSidebarItem(option.id, $event.target.checked)"
|
||||
/>
|
||||
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
|
||||
{{ option.label }}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
@@ -209,45 +209,6 @@ describe('SettingsComponent', () => {
|
||||
fixture.detectChanges()
|
||||
}
|
||||
|
||||
it('supports configuring sidebar items and canceling changes', () => {
|
||||
completeSetup()
|
||||
|
||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
||||
HideableSidebarItemID.Workflows
|
||||
)
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Mail,
|
||||
false
|
||||
)
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
||||
HideableSidebarItemID.Mail
|
||||
)
|
||||
|
||||
component.reset()
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
||||
HideableSidebarItemID.Workflows
|
||||
)
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
||||
HideableSidebarItemID.Mail
|
||||
)
|
||||
})
|
||||
|
||||
it('enables sidebar item controls on general settings until destroyed', () => {
|
||||
completeSetup()
|
||||
|
||||
expect(settingsService.organizingSidebarItems()).toBe(true)
|
||||
|
||||
component.ngOnDestroy()
|
||||
|
||||
expect(settingsService.organizingSidebarItems()).toBe(false)
|
||||
})
|
||||
|
||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||
completeSetup()
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
@@ -288,7 +249,6 @@ describe('SettingsComponent', () => {
|
||||
|
||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||
completeSetup()
|
||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||
@@ -307,10 +267,7 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(34)
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Workflows,
|
||||
])
|
||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
||||
@@ -39,12 +39,7 @@ import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import {
|
||||
GlobalSearchType,
|
||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
} from 'src/app/data/ui-settings'
|
||||
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
@@ -107,14 +102,6 @@ const documentDetailFieldOptions = [
|
||||
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
||||
]
|
||||
|
||||
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
||||
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
|
||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-settings',
|
||||
templateUrl: './settings.component.html',
|
||||
@@ -162,7 +149,6 @@ export class SettingsComponent
|
||||
bulkEditApplyOnClose: new FormControl(null),
|
||||
documentListItemPerPage: new FormControl(null),
|
||||
slimSidebarEnabled: new FormControl(null),
|
||||
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
|
||||
darkModeUseSystem: new FormControl(null),
|
||||
darkModeEnabled: new FormControl(null),
|
||||
darkModeInvertThumbs: new FormControl(null),
|
||||
@@ -200,7 +186,6 @@ export class SettingsComponent
|
||||
|
||||
store: BehaviorSubject<any>
|
||||
storeSub: Subscription
|
||||
sidebarItemsSub: Subscription
|
||||
isDirty$: Observable<boolean>
|
||||
isDirty: boolean = false
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
@@ -218,10 +203,6 @@ export class SettingsComponent
|
||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||
|
||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
||||
id,
|
||||
label: sidebarItemLabels[id],
|
||||
}))
|
||||
|
||||
get systemStatusHasErrors(): boolean {
|
||||
const status = this.systemStatus()
|
||||
@@ -249,10 +230,6 @@ export class SettingsComponent
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.sidebarItemsSub =
|
||||
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
|
||||
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
|
||||
)
|
||||
this.settings.settingsSaved.subscribe(() => {
|
||||
if (!this.savePending) this.initialize()
|
||||
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||
@@ -302,21 +279,14 @@ export class SettingsComponent
|
||||
|
||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||
const section = paramMap.get('section')
|
||||
let navID = SettingsNavIDs.General
|
||||
if (section) {
|
||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||
(navID) => navID.toLowerCase() == section
|
||||
)
|
||||
if (navIDKey) {
|
||||
navID = SettingsNavIDs[navIDKey]
|
||||
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||
}
|
||||
}
|
||||
this.activeNavID.set(navID)
|
||||
this.settings.sidebarHiddenItemsEditing.set(
|
||||
navID === SettingsNavIDs.General
|
||||
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
||||
: null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,7 +310,6 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
||||
),
|
||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
||||
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
|
||||
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||
darkModeInvertThumbs: this.settings.get(
|
||||
@@ -467,12 +436,6 @@ export class SettingsComponent
|
||||
this.settingsForm.patchValue(currentFormValue)
|
||||
}
|
||||
|
||||
if (this.settings.organizingSidebarItems()) {
|
||||
this.settings.sidebarHiddenItemsEditing.set([
|
||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||
])
|
||||
}
|
||||
|
||||
if (this.canViewSystemStatus) {
|
||||
this.systemStatusService.get().subscribe((status) => {
|
||||
this.systemStatus.set(status)
|
||||
@@ -481,18 +444,8 @@ export class SettingsComponent
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.settings.sidebarHiddenItemsEditing.set(null)
|
||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||
this.storeSub && this.storeSub.unsubscribe()
|
||||
this.sidebarItemsSub.unsubscribe()
|
||||
}
|
||||
|
||||
isSidebarItemShown(item: HideableSidebarItemID): boolean {
|
||||
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
|
||||
}
|
||||
|
||||
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
|
||||
this.settings.updateSidebarItemVisibility(item, checked)
|
||||
}
|
||||
|
||||
public saveSettings() {
|
||||
@@ -520,10 +473,6 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||
this.settingsForm.value.slimSidebarEnabled
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||
this.settingsForm.value.sidebarHiddenItems
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||
this.settingsForm.value.darkModeUseSystem
|
||||
@@ -683,11 +632,6 @@ export class SettingsComponent
|
||||
|
||||
reset() {
|
||||
this.settingsForm.patchValue(this.store.getValue())
|
||||
if (this.settings.organizingSidebarItems()) {
|
||||
this.settings.sidebarHiddenItemsEditing.set([
|
||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
clearThemeColor() {
|
||||
|
||||
@@ -86,15 +86,12 @@
|
||||
}
|
||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||
@@ -240,38 +237,29 @@
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" 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"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<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"
|
||||
@@ -334,16 +322,13 @@
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
|
||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
|
||||
<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"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
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>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import {
|
||||
@@ -287,82 +287,6 @@ describe('AppFrameComponent', () => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should hide configured sidebar items', () => {
|
||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Dashboard,
|
||||
HideableSidebarItemID.Workflows,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="workflows"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
||||
.classList
|
||||
).not.toContain('d-none')
|
||||
})
|
||||
|
||||
it('should show hidden items and visibility switches while customizing', () => {
|
||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Dashboard,
|
||||
])
|
||||
settingsService.sidebarHiddenItemsEditing.set([
|
||||
HideableSidebarItemID.Dashboard,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
||||
).toBe(5)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||
.parentElement.classList
|
||||
).not.toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).toContain('opacity-50')
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).not.toContain('pe-5')
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
||||
component.slimSidebarAnimating.set(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
|
||||
component.slimSidebarAnimating.set(false)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).toContain('pe-5')
|
||||
})
|
||||
|
||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { NgClass } from '@angular/common'
|
||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbCollapseModule,
|
||||
@@ -22,11 +21,7 @@ import { Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import {
|
||||
CollapsibleSection,
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
} from 'src/app/data/ui-settings'
|
||||
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -53,7 +48,6 @@ import { ChatComponent } from '../chat/chat/chat.component'
|
||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||
import { LogoComponent } from '../common/logo/logo.component'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||
@@ -82,8 +76,6 @@ const SCROLL_THRESHOLD = 16
|
||||
NgxBootstrapIconsModule,
|
||||
DragDropModule,
|
||||
TourNgBootstrap,
|
||||
FormsModule,
|
||||
SwitchComponent,
|
||||
],
|
||||
})
|
||||
export class AppFrameComponent
|
||||
@@ -106,7 +98,6 @@ export class AppFrameComponent
|
||||
readonly isMenuCollapsed = signal(true)
|
||||
readonly slimSidebarAnimating = signal(false)
|
||||
readonly mobileSearchHidden = signal(false)
|
||||
readonly HideableSidebarItemID = HideableSidebarItemID
|
||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.VERSION
|
||||
)
|
||||
@@ -204,10 +195,6 @@ export class AppFrameComponent
|
||||
}, 200) // slightly longer than css animation for slim sidebar
|
||||
}
|
||||
|
||||
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
|
||||
this.settingsService.updateSidebarItemVisibility(item, visible)
|
||||
}
|
||||
|
||||
toggleAttributesSections(event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div [class.mb-3]="!compact">
|
||||
<div [class.row]="!compact">
|
||||
@if (!horizontal && !compact) {
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
@if (!horizontal) {
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||
{{title}}
|
||||
@@ -17,8 +17,8 @@
|
||||
}
|
||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||
<div class="form-check form-switch">
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
|
||||
@if (horizontal && !compact) {
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||
@if (horizontal) {
|
||||
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||
{{title}}
|
||||
@if (showUnsetNote && isUnset) {
|
||||
|
||||
@@ -48,14 +48,4 @@ describe('SwitchComponent', () => {
|
||||
component.value = undefined
|
||||
expect(component.isUnset).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support a compact layout', () => {
|
||||
component.compact = true
|
||||
component.title = 'Test switch'
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
|
||||
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
|
||||
expect(input.getAttribute('aria-label')).toEqual('Test switch')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,9 +25,6 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
||||
@Input()
|
||||
showUnsetNote: boolean = false
|
||||
|
||||
@Input()
|
||||
compact: boolean = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
@@ -24,16 +24,6 @@ export enum CollapsibleSection {
|
||||
ATTRIBUTES = 'attributes',
|
||||
}
|
||||
|
||||
export enum HideableSidebarItemID {
|
||||
Dashboard = 'dashboard',
|
||||
SavedViews = 'saved_views',
|
||||
Workflows = 'workflows',
|
||||
Mail = 'mail',
|
||||
Documentation = 'documentation',
|
||||
}
|
||||
|
||||
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
|
||||
|
||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||
|
||||
export const SETTINGS_KEYS = {
|
||||
@@ -66,7 +56,6 @@ export const SETTINGS_KEYS = {
|
||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
|
||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||
'general-settings:attributes-sections-collapsed',
|
||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||
@@ -138,11 +127,6 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||
type: 'array',
|
||||
|
||||
@@ -14,11 +14,7 @@ import { CustomFieldDataType } from '../data/custom-field'
|
||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||
import { SavedView } from '../data/saved-view'
|
||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||
import {
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
UiSettings,
|
||||
} from '../data/ui-settings'
|
||||
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
||||
import { PermissionsService } from './permissions.service'
|
||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||
import { SettingsService } from './settings.service'
|
||||
@@ -234,35 +230,6 @@ describe('SettingsService', () => {
|
||||
expect(notesEnabled()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('updates sidebar item visibility', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(false)
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Workflows,
|
||||
false
|
||||
)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(true)
|
||||
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Workflows,
|
||||
true
|
||||
)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('updates setting signals when settings are reinitialized', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}ui_settings/`
|
||||
|
||||
@@ -24,7 +24,6 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||
import { SavedView } from '../data/saved-view'
|
||||
import {
|
||||
HideableSidebarItemID,
|
||||
PAPERLESS_GREEN_HEX,
|
||||
SETTINGS,
|
||||
SETTINGS_KEYS,
|
||||
@@ -314,18 +313,6 @@ export class SettingsService {
|
||||
readonly globalDropzoneEnabled = signal(true)
|
||||
readonly globalDropzoneActive = signal(false)
|
||||
readonly organizingSidebarSavedViews = signal(false)
|
||||
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
|
||||
null
|
||||
)
|
||||
readonly organizingSidebarItems = computed(
|
||||
() => this.sidebarHiddenItemsEditing() !== null
|
||||
)
|
||||
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
|
||||
HideableSidebarItemID[]
|
||||
>()
|
||||
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
|
||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
|
||||
)
|
||||
|
||||
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
||||
DEFAULT_DISPLAY_FIELDS
|
||||
@@ -762,29 +749,6 @@ export class SettingsService {
|
||||
return this.storeSettings()
|
||||
}
|
||||
|
||||
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
|
||||
return (
|
||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
||||
).includes(item)
|
||||
}
|
||||
|
||||
updateSidebarItemVisibility(
|
||||
item: HideableSidebarItemID,
|
||||
visible: boolean
|
||||
): void {
|
||||
const hiddenItems = new Set(
|
||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
||||
)
|
||||
if (visible) {
|
||||
hiddenItems.delete(item)
|
||||
} else {
|
||||
hiddenItems.add(item)
|
||||
}
|
||||
const updatedHiddenItems = [...hiddenItems]
|
||||
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
|
||||
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
|
||||
}
|
||||
|
||||
updateSavedViewsVisibility(
|
||||
dashboardVisibleViewIds: number[],
|
||||
sidebarVisibleViewIds: number[]
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
@@ -379,7 +380,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
||||
)
|
||||
delete_ids = list({*doc_ids, *version_ids})
|
||||
|
||||
Document.objects.filter(id__in=delete_ids).delete()
|
||||
Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4())
|
||||
|
||||
from documents.search import get_backend
|
||||
|
||||
|
||||
@@ -156,6 +156,15 @@ class FileStabilityTracker:
|
||||
logger.debug(f"File disappeared during stability check: {path}")
|
||||
continue
|
||||
|
||||
# Stable, but empty: some scanners create a zero byte placeholder
|
||||
# and only write the page some time later. Consuming it now can
|
||||
# only fail so drop it and let the writer's next event
|
||||
# (or the periodic rescan) bring it back once it has content
|
||||
if not tracked.last_size:
|
||||
to_remove.append(path)
|
||||
logger.debug("Ignoring stable but empty file: %s", path)
|
||||
continue
|
||||
|
||||
# File is stable, we can return it
|
||||
to_yield.append(path)
|
||||
logger.info(f"File is stable: {path}")
|
||||
|
||||
+10
-2
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
@@ -514,13 +515,20 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
def delete(
|
||||
self,
|
||||
*args,
|
||||
transaction_id=None,
|
||||
**kwargs,
|
||||
):
|
||||
# If deleting a root document, move all its versions to trash as well.
|
||||
# Versions must share the root's transaction ID so they are restored
|
||||
# together by django-softdelete.
|
||||
if transaction_id is None:
|
||||
transaction_id = uuid.uuid4()
|
||||
if self.root_document_id is None:
|
||||
Document.objects.filter(root_document=self).delete()
|
||||
Document.objects.filter(root_document=self).delete(
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
return super().delete(
|
||||
*args,
|
||||
transaction_id=transaction_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -207,3 +207,65 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
||||
|
||||
def _make_versioned_document(self) -> tuple[Document, list[Document]]:
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
content="root-content",
|
||||
checksum="root",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
versions = [
|
||||
Document.objects.create(
|
||||
title=f"v{index}",
|
||||
content=f"v{index}-content",
|
||||
checksum=f"v{index}",
|
||||
mime_type="application/pdf",
|
||||
root_document=root,
|
||||
version_index=index,
|
||||
)
|
||||
for index in range(1, 3)
|
||||
]
|
||||
return root, versions
|
||||
|
||||
def test_api_trash_restore_document_restores_its_versions(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document with two versions
|
||||
WHEN:
|
||||
- API request to delete the document
|
||||
- API request to restore it from the trash
|
||||
THEN:
|
||||
- Only the document itself is listed in the trash
|
||||
- A version cannot be restored without its root
|
||||
- The document is restored together with all of its versions
|
||||
"""
|
||||
root, versions = self._make_versioned_document()
|
||||
|
||||
self.client.force_login(user=self.user)
|
||||
self.client.delete(f"/api/documents/{root.pk}/")
|
||||
self.assertEqual(Document.deleted_objects.count(), 3)
|
||||
|
||||
resp = self.client.get("/api/trash/")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["count"], 1)
|
||||
self.assertEqual(resp.data["results"][0]["id"], root.pk)
|
||||
|
||||
# A version cannot be restored while its root remains in the trash.
|
||||
resp = self.client.post(
|
||||
"/api/trash/",
|
||||
{"action": "restore", "documents": [versions[0].pk]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("Restore the root document", resp.data["documents"][0])
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/trash/",
|
||||
{"action": "restore", "documents": [root.pk]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(Document.deleted_objects.count(), 0)
|
||||
self.assertCountEqual(
|
||||
Document.objects.filter(root_document=root).values_list("id", flat=True),
|
||||
[version.pk for version in versions],
|
||||
)
|
||||
|
||||
@@ -392,6 +392,11 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
||||
self.assertFalse(Document.objects.filter(id=version.id).exists())
|
||||
|
||||
Document.deleted_objects.get(id=self.doc1.id).restore(strict=False)
|
||||
|
||||
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
|
||||
self.assertTrue(Document.objects.filter(id=version.id).exists())
|
||||
|
||||
def test_delete_version_document_keeps_root(self) -> None:
|
||||
version = Document.objects.create(
|
||||
checksum="A-v1",
|
||||
|
||||
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
|
||||
checksum="checksum",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
Document.objects.create(
|
||||
version = Document.objects.create(
|
||||
root_document=root,
|
||||
correspondent=root.correspondent,
|
||||
title="Version",
|
||||
@@ -124,6 +124,10 @@ class TestDocument(TestCase):
|
||||
self.assertEqual(Document.objects.count(), 0)
|
||||
self.assertEqual(Document.deleted_objects.count(), 2)
|
||||
|
||||
root.restore(strict=False)
|
||||
|
||||
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
|
||||
|
||||
def test_file_name(self) -> None:
|
||||
doc = Document(
|
||||
mime_type="application/pdf",
|
||||
|
||||
@@ -136,6 +136,23 @@ def wait_for_mock_call(
|
||||
return False
|
||||
|
||||
|
||||
def sleep_past_stability(
|
||||
owner: FileStabilityTracker | ConsumerThread,
|
||||
*,
|
||||
windows: float = 1.5,
|
||||
) -> None:
|
||||
"""
|
||||
Block until a tracked file's stability window has certainly elapsed.
|
||||
|
||||
Args:
|
||||
owner: The tracker, or the consumer thread running one, whose
|
||||
configured stability delay sets the wait.
|
||||
windows: How many stability windows to wait, giving slop for a slow
|
||||
or loaded test runner.
|
||||
"""
|
||||
sleep(owner.stability_delay * windows)
|
||||
|
||||
|
||||
class TestTrackedFile:
|
||||
"""Tests for the TrackedFile dataclass."""
|
||||
|
||||
@@ -261,6 +278,56 @@ class TestFileStabilityTracker:
|
||||
assert len(stable) == 0
|
||||
assert stability_tracker.pending_count == 1
|
||||
|
||||
def test_get_stable_files_skips_empty_file(
|
||||
self,
|
||||
stability_tracker: FileStabilityTracker,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A zero byte file, tracked and past its stability delay
|
||||
WHEN:
|
||||
- Stable files are collected
|
||||
THEN:
|
||||
- The file is not yielded for consumption
|
||||
- The file is dropped from tracking rather than held, so an
|
||||
abandoned placeholder does not keep the watch loop awake
|
||||
"""
|
||||
empty = tmp_path / "scan.pdf"
|
||||
empty.write_bytes(b"")
|
||||
stability_tracker.track(empty, Change.added)
|
||||
sleep_past_stability(stability_tracker)
|
||||
|
||||
stable = list(stability_tracker.get_stable_files())
|
||||
|
||||
assert stable == []
|
||||
assert stability_tracker.pending_count == 0
|
||||
|
||||
def test_empty_file_is_yielded_once_content_arrives(
|
||||
self,
|
||||
stability_tracker: FileStabilityTracker,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A zero byte file which was dropped from tracking while empty
|
||||
WHEN:
|
||||
- The writer fills the file and a new event re-tracks it
|
||||
THEN:
|
||||
- The file is yielded for consumption once it is stable
|
||||
"""
|
||||
target = tmp_path / "scan.pdf"
|
||||
target.write_bytes(b"")
|
||||
stability_tracker.track(target, Change.added)
|
||||
sleep_past_stability(stability_tracker)
|
||||
assert list(stability_tracker.get_stable_files()) == []
|
||||
|
||||
target.write_bytes(b"%PDF-1.4 content")
|
||||
stability_tracker.track(target, Change.modified)
|
||||
sleep_past_stability(stability_tracker)
|
||||
|
||||
assert list(stability_tracker.get_stable_files()) == [target]
|
||||
|
||||
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
|
||||
"""Test deleted file is not returned during stability check."""
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
@@ -879,6 +946,51 @@ class TestCommandWatch:
|
||||
|
||||
mock_consume_file_delay.apply_async.assert_called()
|
||||
|
||||
def test_scanner_placeholder_is_not_consumed_while_empty(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
start_consumer: Callable[..., ConsumerThread],
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A scanner which creates a zero byte placeholder and only writes
|
||||
the page some time later (GH discussion #13969)
|
||||
WHEN:
|
||||
- The placeholder sits untouched well past the stability delay
|
||||
- The scanner then writes the real content
|
||||
THEN:
|
||||
- The empty placeholder is never queued, as it could only fail
|
||||
with "Unsupported mime type inode/x-empty"
|
||||
- The file is queued exactly once, when the content lands
|
||||
"""
|
||||
thread = start_consumer(stability_delay=0.2)
|
||||
|
||||
target = consumption_dir / "scan.pdf"
|
||||
target.write_bytes(b"") # the scanner's placeholder
|
||||
|
||||
# Well past the stability delay: the old behaviour queued it here.
|
||||
sleep_past_stability(thread, windows=5)
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
assert mock_consume_file_delay.apply_async.call_count == 0
|
||||
|
||||
shutil.copy(sample_pdf, target) # the scanner finishes the page
|
||||
|
||||
assert wait_for_mock_call(
|
||||
mock_consume_file_delay.apply_async,
|
||||
timeout_s=5.0,
|
||||
)
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
|
||||
assert mock_consume_file_delay.apply_async.call_count == 1
|
||||
queued_doc = mock_consume_file_delay.apply_async.call_args.kwargs["kwargs"][
|
||||
"input_doc"
|
||||
]
|
||||
assert queued_doc.original_file.name == "scan.pdf"
|
||||
|
||||
def test_ignores_macos_files(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
|
||||
@@ -32,6 +32,7 @@ from documents.signals.handlers import update_llm_suggestions_cache
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless_ai.exceptions import LLMProviderError
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
|
||||
@@ -737,6 +738,38 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="openai-like",
|
||||
)
|
||||
def test_ai_suggestions_with_llm_provider_error(
|
||||
self,
|
||||
mock_get_ai_classification,
|
||||
) -> None:
|
||||
mock_get_ai_classification.side_effect = LLMProviderError(
|
||||
"confidential provider response",
|
||||
)
|
||||
|
||||
self.client.force_login(user=self.user)
|
||||
response = self.client.get(
|
||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"ai": [
|
||||
"AI backend rejected the request. Check logs for details.",
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertNotIn("confidential provider response", response.content.decode())
|
||||
self.assertIsNone(
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
|
||||
+30
-2
@@ -252,6 +252,7 @@ from paperless.views import StandardPagination
|
||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||
from paperless_ai.ai_classifier import get_llm_output_language
|
||||
from paperless_ai.chat import stream_chat_with_documents
|
||||
from paperless_ai.exceptions import LLMProviderError
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
from paperless_ai.matching import extract_unmatched_names
|
||||
from paperless_ai.matching import match_correspondents_by_name
|
||||
@@ -1603,6 +1604,22 @@ class DocumentViewSet(
|
||||
{"ai": [_("AI backend request timed out.")]},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
except LLMProviderError:
|
||||
logger.exception(
|
||||
"AI backend rejected the request for document %s",
|
||||
doc.pk,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"ai": [
|
||||
_(
|
||||
"AI backend rejected the request. "
|
||||
"Check logs for details.",
|
||||
),
|
||||
],
|
||||
},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
set_llm_suggestions_cache(
|
||||
doc.pk,
|
||||
llm_suggestions,
|
||||
@@ -5437,7 +5454,10 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
|
||||
model = Document
|
||||
|
||||
queryset = Document.deleted_objects.all()
|
||||
# A version is listed separately only when its root is not in the trash.
|
||||
queryset = Document.deleted_objects.exclude(
|
||||
root_document_id__in=Document.deleted_objects.values("id"),
|
||||
)
|
||||
|
||||
def get(self, request: Request, format: str | None = None) -> Response:
|
||||
self.serializer_class = DocumentSerializer
|
||||
@@ -5468,7 +5488,15 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
action = serializer.validated_data.get("action")
|
||||
if action == "restore":
|
||||
restored = list(Document.deleted_objects.filter(id__in=doc_ids))
|
||||
restored = list(self.get_queryset().filter(id__in=doc_ids))
|
||||
if len(restored) != len(doc_ids):
|
||||
raise ValidationError(
|
||||
{
|
||||
"documents": [
|
||||
"Restore the root document instead of one of its versions.",
|
||||
],
|
||||
},
|
||||
)
|
||||
for doc in restored:
|
||||
doc.restore(strict=False)
|
||||
if restored:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,21 +4,24 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents.models import Document
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import user_is_unrestricted
|
||||
from paperless.config import AIConfig
|
||||
from paperless_ai.base_model import ClassificationSuggestions
|
||||
from paperless_ai.base_model import TaxonomyChoiceDict
|
||||
from paperless_ai.base_model import classification_suggestions_to_model
|
||||
from paperless_ai.client import AIClient
|
||||
from paperless_ai.db import db_connection_released
|
||||
from paperless_ai.indexing import _node_document_ids
|
||||
from paperless_ai.indexing import retrieve_similar_nodes
|
||||
from paperless_ai.indexing import truncate_content
|
||||
from paperless_ai.prompts.context import ClassificationPromptContext
|
||||
from paperless_ai.prompts.context import LocalizationPromptContext
|
||||
from paperless_ai.prompts.context import RagContextPromptContext
|
||||
from paperless_ai.prompts.render import render_prompt
|
||||
from paperless_ai.taxonomy import SimilarDocument
|
||||
from paperless_ai.taxonomy import TaxonomyCandidates
|
||||
from paperless_ai.taxonomy import _node_document_weights
|
||||
from paperless_ai.taxonomy import build_taxonomy_candidates
|
||||
from paperless_ai.taxonomy import empty_taxonomy_candidates
|
||||
from paperless_ai.taxonomy import format_taxonomy_for_prompt
|
||||
@@ -37,6 +40,48 @@ logger = logging.getLogger("paperless_ai.rag_classifier")
|
||||
TAXONOMY_CANDIDATE_TOP_K = 15
|
||||
|
||||
|
||||
def _fulltext_similar_documents(
|
||||
document: Document,
|
||||
user: User | None,
|
||||
top_k: int,
|
||||
) -> list[SimilarDocument]:
|
||||
"""Rank-based fallback when no embedding backend is configured. Uses
|
||||
Tantivy's "More Like This" (term-overlap similarity) instead of vector
|
||||
similarity - cruder, but far better than no candidates at all.
|
||||
more_like_this_ids returns only a ranked ID list, no scores, so weight is
|
||||
synthesized from rank (descending from top_k) rather than claiming a
|
||||
similarity magnitude that doesn't exist. An unrestricted user (none, or an
|
||||
active superuser - see user_is_unrestricted) is normalized to ``None``
|
||||
before calling, since the backend's permission filter has no superuser
|
||||
short-circuit of its own. Results are re-checked with
|
||||
restrict_queryset_to_visible() since Tantivy's indexed permission fields
|
||||
lag the DB via async reindexing.
|
||||
"""
|
||||
from documents.search import get_backend
|
||||
|
||||
unrestricted = user_is_unrestricted(user)
|
||||
search_user = None if unrestricted else user
|
||||
backend = get_backend()
|
||||
similar_ids = backend.more_like_this_ids(
|
||||
document.pk,
|
||||
user=search_user,
|
||||
limit=top_k,
|
||||
)
|
||||
if not unrestricted:
|
||||
allowed_ids = set(
|
||||
restrict_queryset_to_visible(
|
||||
Document.objects.filter(pk__in=similar_ids),
|
||||
user,
|
||||
"view_document",
|
||||
).values_list("pk", flat=True),
|
||||
)
|
||||
similar_ids = [doc_id for doc_id in similar_ids if doc_id in allowed_ids]
|
||||
return [
|
||||
SimilarDocument(document_id=doc_id, weight=float(top_k - rank))
|
||||
for rank, doc_id in enumerate(similar_ids)
|
||||
]
|
||||
|
||||
|
||||
def get_language_name(language_code: str) -> str:
|
||||
normalized_language_code = language_code.lower()
|
||||
for code, name in settings.LANGUAGES:
|
||||
@@ -136,43 +181,52 @@ def get_taxonomy_context(
|
||||
user: User | None = None,
|
||||
max_docs: int = 5,
|
||||
) -> tuple[TaxonomyCandidates, str]:
|
||||
"""One retrieval feeds both taxonomy candidates and RAG text context.
|
||||
On any retrieval failure, degrades to empty candidates/context rather than
|
||||
propagating the exception - a vector-store outage should not block
|
||||
classification, only its RAG-assisted enrichment.
|
||||
"""One retrieval feeds both taxonomy candidates and RAG text context. Uses
|
||||
vector similarity when an embedding backend is configured, otherwise
|
||||
falls back to Tantivy full-text "More Like This" similarity - see
|
||||
_fulltext_similar_documents. On any retrieval failure, degrades to empty
|
||||
candidates/context rather than propagating the exception - neither a
|
||||
vector-store outage nor a search-index issue should block classification,
|
||||
only its context-assisted enrichment.
|
||||
"""
|
||||
ai_config = AIConfig()
|
||||
try:
|
||||
# None means "no restriction" to retrieve_similar_nodes. A superuser
|
||||
# (like no user at all) can see every document, so skip materializing
|
||||
# every visible pk into a Python list and passing it through as an IN
|
||||
# filter: for a large library that is a wasted quadratic scan in the
|
||||
# vector store at best, and past ~32,763 documents a hard
|
||||
# sqlite3.OperationalError (SQLite's bound-parameter limit) at worst.
|
||||
# get_objects_for_user_owner_aware() would return every Document for a
|
||||
# superuser anyway (guardian's own with_superuser shortcut), so this
|
||||
# changes nothing about which documents are considered -- only how we
|
||||
# get there.
|
||||
visible_document_ids = (
|
||||
None
|
||||
if user is None or user.is_superuser
|
||||
else list(
|
||||
get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_document",
|
||||
Document,
|
||||
).values_list("pk", flat=True),
|
||||
if ai_config.llm_embedding_backend:
|
||||
# None means "no restriction" to retrieve_similar_nodes. An
|
||||
# unrestricted user (no user at all, or an active superuser -- see
|
||||
# user_is_unrestricted) can see every document, so skip
|
||||
# materializing every visible pk into a Python list and passing it
|
||||
# through as an IN filter: for a large library that is a wasted
|
||||
# quadratic scan in the vector store at best, and past ~32,763
|
||||
# documents a hard sqlite3.OperationalError (SQLite's
|
||||
# bound-parameter limit) at worst.
|
||||
# permitted_object_ids() has its own superuser shortcut that would
|
||||
# return every Document's id anyway, so this changes nothing about
|
||||
# which documents are considered -- only how we get there.
|
||||
visible_document_ids = (
|
||||
None
|
||||
if user_is_unrestricted(user)
|
||||
else list(permitted_object_ids(user, Document, "view_document"))
|
||||
)
|
||||
nodes = retrieve_similar_nodes(
|
||||
document,
|
||||
top_k=TAXONOMY_CANDIDATE_TOP_K,
|
||||
document_ids=visible_document_ids,
|
||||
)
|
||||
similar_documents = _node_document_weights(nodes)
|
||||
else:
|
||||
# See _fulltext_similar_documents: it applies its own permission
|
||||
# filter via `user`, so no visible-document-id list is needed here.
|
||||
similar_documents = _fulltext_similar_documents(
|
||||
document,
|
||||
user,
|
||||
top_k=TAXONOMY_CANDIDATE_TOP_K,
|
||||
)
|
||||
)
|
||||
nodes = retrieve_similar_nodes(
|
||||
document,
|
||||
top_k=TAXONOMY_CANDIDATE_TOP_K,
|
||||
document_ids=visible_document_ids,
|
||||
)
|
||||
|
||||
candidates = build_taxonomy_candidates(nodes, user)
|
||||
candidates = build_taxonomy_candidates(similar_documents, user)
|
||||
|
||||
# ``nodes`` are already ordered by descending vector similarity; don't lose it.
|
||||
similar_document_ids = list(dict.fromkeys(_node_document_ids(nodes)))
|
||||
# similar_documents is already ordered by descending weight; don't lose it.
|
||||
similar_document_ids = [s["document_id"] for s in similar_documents]
|
||||
similar_documents_by_id = Document.objects.in_bulk(similar_document_ids)
|
||||
similar_docs = [
|
||||
similar_documents_by_id[document_id]
|
||||
@@ -186,8 +240,8 @@ def get_taxonomy_context(
|
||||
context_blocks.append(f"TITLE: {title}\n{text}")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to retrieve RAG neighbours for document %s; continuing "
|
||||
"without taxonomy candidates or similar-document context.",
|
||||
"Failed to retrieve similar-document context for document %s; "
|
||||
"continuing without taxonomy candidates or similar-document context.",
|
||||
document.pk,
|
||||
)
|
||||
return empty_taxonomy_candidates(), ""
|
||||
@@ -241,17 +295,13 @@ def get_ai_document_classification(
|
||||
) -> ClassificationSuggestions:
|
||||
ai_config = AIConfig()
|
||||
|
||||
if ai_config.llm_embedding_backend:
|
||||
candidates, context = get_taxonomy_context(document, user)
|
||||
prompt = build_prompt_with_rag(
|
||||
document,
|
||||
ai_config,
|
||||
candidates=candidates,
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
candidates = empty_taxonomy_candidates()
|
||||
prompt = build_prompt_without_rag(document, ai_config, candidates=candidates)
|
||||
candidates, context = get_taxonomy_context(document, user)
|
||||
prompt = build_prompt_with_rag(
|
||||
document,
|
||||
ai_config,
|
||||
candidates=candidates,
|
||||
context=context,
|
||||
)
|
||||
|
||||
client = AIClient()
|
||||
# Hand the pooled DB connection back while the (slow) LLM query runs so it
|
||||
|
||||
@@ -22,6 +22,7 @@ from paperless.network import validate_outbound_http_url
|
||||
from paperless_ai.base_model import ClassificationSuggestions
|
||||
from paperless_ai.base_model import DocumentClassifierSchema
|
||||
from paperless_ai.base_model import model_to_classification_suggestions
|
||||
from paperless_ai.exceptions import LLMProviderError
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
logger = logging.getLogger("paperless_ai.client")
|
||||
@@ -132,7 +133,7 @@ class AIClient:
|
||||
from llama_index.core.llms import ChatMessage
|
||||
|
||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
||||
with self._normalize_timeouts():
|
||||
with self._normalize_errors():
|
||||
result = self.llm.chat(
|
||||
[ChatMessage(role="user", content=prompt)],
|
||||
format=DocumentClassifierSchema.model_json_schema(),
|
||||
@@ -153,7 +154,7 @@ class AIClient:
|
||||
content=f"{prompt}\n\n"
|
||||
f"Answer by calling the {tool.metadata.name} tool. Do not write the answer as text.",
|
||||
)
|
||||
with self._normalize_timeouts():
|
||||
with self._normalize_errors():
|
||||
result = self.llm.chat_with_tools(
|
||||
tools=[tool],
|
||||
user_msg=user_msg,
|
||||
@@ -173,7 +174,7 @@ class AIClient:
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _normalize_timeouts(self) -> Iterator[None]:
|
||||
def _normalize_errors(self) -> Iterator[None]:
|
||||
try:
|
||||
yield
|
||||
except httpx.TimeoutException as exc:
|
||||
@@ -181,8 +182,23 @@ class AIClient:
|
||||
except Exception as exc:
|
||||
if self._is_openai_timeout(exc):
|
||||
raise LLMTimeoutError from exc
|
||||
if self._is_provider_error(exc):
|
||||
raise LLMProviderError from exc
|
||||
raise
|
||||
|
||||
def _is_provider_error(self, exc: Exception) -> bool:
|
||||
if self.settings.llm_backend == LLMBackend.OLLAMA:
|
||||
from ollama import ResponseError
|
||||
|
||||
return isinstance(exc, ResponseError)
|
||||
|
||||
if self.settings.llm_backend == LLMBackend.OPENAI_LIKE:
|
||||
from openai import APIStatusError
|
||||
|
||||
return isinstance(exc, APIStatusError)
|
||||
|
||||
return False
|
||||
|
||||
def _is_openai_timeout(self, exc: Exception) -> bool:
|
||||
if self.settings.llm_backend != LLMBackend.OPENAI_LIKE:
|
||||
return False
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
class LLMTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LLMProviderError(Exception):
|
||||
"""The LLM backend rejected the request."""
|
||||
|
||||
@@ -721,20 +721,3 @@ def retrieve_similar_nodes(
|
||||
continue
|
||||
filtered.append(node)
|
||||
return filtered
|
||||
|
||||
|
||||
def _node_document_ids(nodes: list["NodeWithScore"]) -> list[int]:
|
||||
document_ids: list[int] = []
|
||||
for node in nodes:
|
||||
document_id = node.metadata.get("document_id")
|
||||
if document_id is None: # pragma: no cover
|
||||
# See the matching guard in retrieve_similar_nodes() above.
|
||||
continue
|
||||
try:
|
||||
document_ids.append(int(document_id))
|
||||
except ValueError: # pragma: no cover
|
||||
logger.warning(
|
||||
"Skipping LLM index result with invalid document_id %r.",
|
||||
document_id,
|
||||
)
|
||||
return document_ids
|
||||
|
||||
@@ -31,6 +31,11 @@ class TaxonomyCandidate(TypedDict):
|
||||
weight: float
|
||||
|
||||
|
||||
class SimilarDocument(TypedDict):
|
||||
document_id: int
|
||||
weight: float
|
||||
|
||||
|
||||
class TaxonomyCandidates(TypedDict):
|
||||
tags: list[TaxonomyCandidate]
|
||||
document_types: list[TaxonomyCandidate]
|
||||
@@ -49,10 +54,10 @@ def empty_taxonomy_candidates() -> TaxonomyCandidates:
|
||||
)
|
||||
|
||||
|
||||
def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
|
||||
"""document_id -> that node's similarity score, summed if a document_id
|
||||
appears more than once across the retrieved nodes (e.g. multiple chunks
|
||||
of the same source document)."""
|
||||
def _node_document_weights(nodes: list["NodeWithScore"]) -> list[SimilarDocument]:
|
||||
"""Sum each node's similarity score into its document_id (a document can
|
||||
appear via multiple chunks/nodes) and return one SimilarDocument per
|
||||
distinct document_id."""
|
||||
weights: dict[int, float] = defaultdict(float)
|
||||
for node in nodes:
|
||||
document_id = node.metadata.get("document_id")
|
||||
@@ -65,7 +70,14 @@ def _node_document_weights(nodes: list["NodeWithScore"]) -> dict[int, float]:
|
||||
weights[int(document_id)] += float(node.score or 0.0)
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
continue
|
||||
return weights
|
||||
return sorted(
|
||||
(
|
||||
SimilarDocument(document_id=document_id, weight=weight)
|
||||
for document_id, weight in weights.items()
|
||||
),
|
||||
key=lambda similar: similar["weight"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def _visible_ranked_candidates(
|
||||
@@ -101,21 +113,26 @@ def _visible_ranked_candidates(
|
||||
|
||||
|
||||
def build_taxonomy_candidates(
|
||||
nodes: list["NodeWithScore"],
|
||||
similar_documents: list[SimilarDocument],
|
||||
user: User | None,
|
||||
) -> TaxonomyCandidates:
|
||||
"""Resolve each neighbour node's document_id to a live Document, read its
|
||||
*current* tags/type/correspondent/storage_path via the ORM (never the
|
||||
possibly-stale names cached in vector-index node metadata), weight each
|
||||
distinct taxonomy object by aggregate neighbour similarity, permission-filter
|
||||
"""Resolve each similar document's id to a live Document, read its
|
||||
*current* tags/type/correspondent/storage_path via the ORM (never any
|
||||
possibly-stale names an adapter's source might have cached), weight each
|
||||
distinct taxonomy object by aggregate similarity weight, permission-filter
|
||||
against what ``user`` can see, and return each category ranked by weight
|
||||
and capped.
|
||||
and capped. ``similar_documents`` may come from either the vector-RAG
|
||||
adapter or the full-text fallback adapter - both produce this same shape.
|
||||
"""
|
||||
|
||||
document_weights = _node_document_weights(nodes)
|
||||
if not document_weights:
|
||||
if not similar_documents:
|
||||
return empty_taxonomy_candidates()
|
||||
|
||||
# Both adapters guarantee at most one SimilarDocument per document_id, so
|
||||
# this never silently drops a duplicate's weight.
|
||||
document_weights: dict[int, float] = {
|
||||
s["document_id"]: s["weight"] for s in similar_documents
|
||||
}
|
||||
|
||||
# Only .tags.all() needs prefetching (a reverse M2M, one extra query for
|
||||
# the whole batch). document_type/correspondent/storage_path are read
|
||||
# below via their *_id columns (neighbour.document_type_id, etc.), which
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from collections.abc import Generator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
@@ -6,18 +7,24 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from django.test import override_settings
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import remove_perm
|
||||
|
||||
from documents.models import Document
|
||||
from documents.search import TantivyBackend
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.factories import UserFactory
|
||||
from paperless.config import AIConfig
|
||||
from paperless_ai.ai_classifier import TAXONOMY_CANDIDATE_TOP_K
|
||||
from paperless_ai.ai_classifier import _fulltext_similar_documents
|
||||
from paperless_ai.ai_classifier import build_localization_prompt
|
||||
from paperless_ai.ai_classifier import build_prompt_with_rag
|
||||
from paperless_ai.ai_classifier import build_prompt_without_rag
|
||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||
from paperless_ai.ai_classifier import get_language_name
|
||||
from paperless_ai.ai_classifier import get_taxonomy_context
|
||||
from paperless_ai.taxonomy import SimilarDocument
|
||||
from paperless_ai.taxonomy import TaxonomyCandidate
|
||||
from paperless_ai.taxonomy import TaxonomyCandidates
|
||||
|
||||
@@ -220,12 +227,10 @@ def test_use_rag_if_configured(
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("paperless_ai.client.AIClient.run_llm_query")
|
||||
@patch("paperless_ai.ai_classifier.build_prompt_without_rag")
|
||||
@patch("paperless_ai.ai_classifier.AIConfig")
|
||||
@patch("paperless_ai.ai_classifier.build_prompt_with_rag")
|
||||
@override_settings(LLM_BACKEND="ollama", LLM_MODEL="some_model")
|
||||
def test_use_without_rag_if_not_configured(
|
||||
mock_ai_config,
|
||||
mock_build_prompt_without_rag,
|
||||
def test_use_rag_prompt_even_without_embedding_backend(
|
||||
mock_build_prompt_with_rag,
|
||||
mock_run_llm_query,
|
||||
mock_document,
|
||||
):
|
||||
@@ -235,13 +240,13 @@ def test_use_without_rag_if_not_configured(
|
||||
WHEN:
|
||||
- get_ai_document_classification() is called
|
||||
THEN:
|
||||
- The non-RAG prompt builder is used
|
||||
- The RAG-context prompt builder is still used (fed by the full-text
|
||||
fallback's context/candidates instead of the vector store's)
|
||||
"""
|
||||
mock_ai_config.return_value.llm_embedding_backend = None
|
||||
mock_build_prompt_without_rag.return_value = "Prompt without RAG"
|
||||
mock_build_prompt_with_rag.return_value = "Prompt with RAG"
|
||||
mock_run_llm_query.return_value = NESTED_SUGGESTIONS
|
||||
get_ai_document_classification(mock_document)
|
||||
mock_build_prompt_without_rag.assert_called_once()
|
||||
mock_build_prompt_with_rag.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -320,6 +325,7 @@ def test_build_localization_prompt_preserves_unicode_characters():
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_get_taxonomy_context_assembles_rag_text_and_candidates():
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -354,6 +360,7 @@ def test_get_taxonomy_context_assembles_rag_text_and_candidates():
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents():
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -424,6 +431,7 @@ def test_get_taxonomy_context_preserves_similarity_order_and_distinct_documents(
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_get_taxonomy_context_no_similar_docs():
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -447,6 +455,67 @@ def test_get_taxonomy_context_no_similar_docs():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_taxonomy_context_uses_fulltext_fallback_when_no_embedding_backend(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- No LLM embedding backend is configured (the default test settings)
|
||||
WHEN:
|
||||
- get_taxonomy_context() is called
|
||||
THEN:
|
||||
- _fulltext_similar_documents() is called with the document, the user
|
||||
and TAXONOMY_CANDIDATE_TOP_K
|
||||
- retrieve_similar_nodes() (the vector path) is never called
|
||||
"""
|
||||
document = DocumentFactory.create(content="Some content")
|
||||
mock_fulltext = mocker.patch(
|
||||
"paperless_ai.ai_classifier._fulltext_similar_documents",
|
||||
return_value=[],
|
||||
)
|
||||
mock_retrieve = mocker.patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
||||
|
||||
get_taxonomy_context(document, user=None)
|
||||
|
||||
mock_fulltext.assert_called_once_with(
|
||||
document,
|
||||
None,
|
||||
top_k=TAXONOMY_CANDIDATE_TOP_K,
|
||||
)
|
||||
mock_retrieve.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_get_taxonomy_context_uses_vector_path_when_embedding_backend_configured(
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An LLM embedding backend is configured
|
||||
WHEN:
|
||||
- get_taxonomy_context() is called
|
||||
THEN:
|
||||
- retrieve_similar_nodes() (the vector path) is called
|
||||
- _fulltext_similar_documents() (the no-embedding-backend fallback)
|
||||
is never called
|
||||
"""
|
||||
document = DocumentFactory.create(content="Some content")
|
||||
mock_retrieve = mocker.patch(
|
||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||
return_value=[],
|
||||
)
|
||||
mock_fulltext = mocker.patch(
|
||||
"paperless_ai.ai_classifier._fulltext_similar_documents",
|
||||
)
|
||||
|
||||
get_taxonomy_context(document, user=None)
|
||||
|
||||
mock_retrieve.assert_called_once()
|
||||
mock_fulltext.assert_not_called()
|
||||
|
||||
|
||||
class TestGetTaxonomyContextVisibility:
|
||||
"""get_taxonomy_context must not materialize every visible document id
|
||||
for a user who can already see the whole library: a superuser (like no
|
||||
@@ -459,6 +528,7 @@ class TestGetTaxonomyContextVisibility:
|
||||
"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_skips_permission_lookup_for_superuser(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
@@ -477,17 +547,18 @@ class TestGetTaxonomyContextVisibility:
|
||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||
return_value=[],
|
||||
)
|
||||
mock_get_objects = mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
)
|
||||
user = UserFactory.create(is_superuser=True)
|
||||
|
||||
get_taxonomy_context(document, user)
|
||||
|
||||
mock_get_objects.assert_not_called()
|
||||
mock_permitted.assert_not_called()
|
||||
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_skips_permission_lookup_when_no_user(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
@@ -506,16 +577,17 @@ class TestGetTaxonomyContextVisibility:
|
||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||
return_value=[],
|
||||
)
|
||||
mock_get_objects = mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
)
|
||||
|
||||
get_taxonomy_context(document, None)
|
||||
|
||||
mock_get_objects.assert_not_called()
|
||||
mock_permitted.assert_not_called()
|
||||
assert mock_retrieve.call_args.kwargs["document_ids"] is None
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
def test_restricts_to_visible_documents_for_non_superuser(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
@@ -526,7 +598,7 @@ class TestGetTaxonomyContextVisibility:
|
||||
WHEN:
|
||||
- get_taxonomy_context() is called
|
||||
THEN:
|
||||
- The user's visible document ids are looked up and passed to
|
||||
- The user's permitted document ids are looked up and passed to
|
||||
retrieve_similar_nodes() as a restriction
|
||||
"""
|
||||
document = DocumentFactory.create(content="Some content")
|
||||
@@ -534,21 +606,232 @@ class TestGetTaxonomyContextVisibility:
|
||||
"paperless_ai.ai_classifier.retrieve_similar_nodes",
|
||||
return_value=[],
|
||||
)
|
||||
mock_queryset = mocker.MagicMock()
|
||||
mock_queryset.values_list.return_value = [1, 2, 3]
|
||||
mock_get_objects = mocker.patch(
|
||||
"paperless_ai.ai_classifier.get_objects_for_user_owner_aware",
|
||||
return_value=mock_queryset,
|
||||
mock_permitted = mocker.patch(
|
||||
"paperless_ai.ai_classifier.permitted_object_ids",
|
||||
return_value=[1, 2, 3],
|
||||
)
|
||||
user = UserFactory.create(is_superuser=False)
|
||||
|
||||
get_taxonomy_context(document, user)
|
||||
|
||||
mock_get_objects.assert_called_once_with(user, "view_document", Document)
|
||||
mock_permitted.assert_called_once_with(user, Document, "view_document")
|
||||
assert mock_retrieve.call_args.kwargs["document_ids"] == [1, 2, 3]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFulltextSimilarDocuments:
|
||||
"""_fulltext_similar_documents is the no-embedding-backend fallback: it
|
||||
asks the Tantivy full-text index for "More Like This" neighbours instead
|
||||
of the vector store, and synthesizes a rank-based weight since Tantivy's
|
||||
more_like_this_ids returns only an ordered id list, no scores.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def fulltext_backend(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> Generator[TantivyBackend, None, None]:
|
||||
"""An in-memory Tantivy backend, wired up as the module-level
|
||||
singleton _fulltext_similar_documents resolves via get_backend()."""
|
||||
backend = TantivyBackend(path=None)
|
||||
backend.open()
|
||||
mocker.patch("documents.search.get_backend", return_value=backend)
|
||||
try:
|
||||
yield backend
|
||||
finally:
|
||||
backend.close()
|
||||
|
||||
def test_ranks_by_rank_based_weight_descending(
|
||||
self,
|
||||
fulltext_backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A source document and two similar documents indexed in Tantivy
|
||||
WHEN:
|
||||
- _fulltext_similar_documents() is called
|
||||
THEN:
|
||||
- Each result's weight reflects its rank (first result weighted
|
||||
higher than the second), not a raw similarity score
|
||||
"""
|
||||
source = DocumentFactory.create(content="quarterly financial report details")
|
||||
first = DocumentFactory.create(content="quarterly financial report details")
|
||||
second = DocumentFactory.create(content="financial report")
|
||||
for doc in (source, first, second):
|
||||
fulltext_backend.add_or_update(doc)
|
||||
|
||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
||||
|
||||
assert len(result) == 2
|
||||
weight_by_id = {s["document_id"]: s["weight"] for s in result}
|
||||
assert weight_by_id[first.pk] > weight_by_id[second.pk]
|
||||
|
||||
def test_excludes_source_document(
|
||||
self,
|
||||
fulltext_backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A source document indexed in Tantivy with no other documents
|
||||
WHEN:
|
||||
- _fulltext_similar_documents() is called
|
||||
THEN:
|
||||
- An empty list is returned - the source document is never its
|
||||
own similar document
|
||||
"""
|
||||
source = DocumentFactory.create(content="unique unrelated content")
|
||||
fulltext_backend.add_or_update(source)
|
||||
|
||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_empty_index_returns_empty_list(
|
||||
self,
|
||||
fulltext_backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document that has never been indexed (fresh/empty Tantivy index)
|
||||
WHEN:
|
||||
- _fulltext_similar_documents() is called
|
||||
THEN:
|
||||
- An empty list is returned rather than raising
|
||||
"""
|
||||
source = DocumentFactory.create(content="never indexed")
|
||||
|
||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_respects_top_k_limit(
|
||||
self,
|
||||
fulltext_backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A source document and four similar documents indexed
|
||||
WHEN:
|
||||
- _fulltext_similar_documents() is called with top_k=2
|
||||
THEN:
|
||||
- At most 2 results are returned
|
||||
"""
|
||||
source = DocumentFactory.create(content="shared overlapping keyword text")
|
||||
fulltext_backend.add_or_update(source)
|
||||
for _ in range(4):
|
||||
fulltext_backend.add_or_update(
|
||||
DocumentFactory.create(content="shared overlapping keyword text"),
|
||||
)
|
||||
|
||||
result = _fulltext_similar_documents(source, user=None, top_k=2)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_result_shape_is_similar_document(
|
||||
self,
|
||||
fulltext_backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A source document and one similar document indexed
|
||||
WHEN:
|
||||
- _fulltext_similar_documents() is called
|
||||
THEN:
|
||||
- Each result is a SimilarDocument (document_id + weight only)
|
||||
"""
|
||||
source = DocumentFactory.create(content="shared content phrase")
|
||||
other = DocumentFactory.create(content="shared content phrase")
|
||||
fulltext_backend.add_or_update(source)
|
||||
fulltext_backend.add_or_update(other)
|
||||
|
||||
result = _fulltext_similar_documents(source, user=None, top_k=5)
|
||||
|
||||
# rank 0 (the only/best result) with top_k=5 -> weight = top_k - rank = 5.0,
|
||||
# per the "first result gets top_k, the last gets 1" formula.
|
||||
assert result == [SimilarDocument(document_id=other.pk, weight=5.0)]
|
||||
|
||||
def test_superuser_sees_other_users_documents(
|
||||
self,
|
||||
fulltext_backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A source document owned by one user and a similar document
|
||||
owned by a different user, with no sharing between them
|
||||
WHEN:
|
||||
- _fulltext_similar_documents() is called with a superuser
|
||||
THEN:
|
||||
- The other user's document is still returned as a similar
|
||||
document - a superuser must not be narrowed by the backend's
|
||||
owner-based permission filter
|
||||
"""
|
||||
owner = UserFactory.create()
|
||||
other_owner = UserFactory.create()
|
||||
superuser = UserFactory.create(is_superuser=True)
|
||||
source = DocumentFactory.create(
|
||||
content="shared content phrase",
|
||||
owner=owner,
|
||||
)
|
||||
other = DocumentFactory.create(
|
||||
content="shared content phrase",
|
||||
owner=other_owner,
|
||||
)
|
||||
fulltext_backend.add_or_update(source)
|
||||
fulltext_backend.add_or_update(other)
|
||||
|
||||
result = _fulltext_similar_documents(source, user=superuser, top_k=5)
|
||||
|
||||
assert [s["document_id"] for s in result] == [other.pk]
|
||||
|
||||
def test_excludes_stale_permitted_document_for_regular_user(
|
||||
self,
|
||||
fulltext_backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A regular (non-superuser) user
|
||||
- A similar document the user is permitted to view, and another
|
||||
similar document indexed while the user still had view
|
||||
permission but which has since had that permission revoked in
|
||||
the database, i.e. the Tantivy index has stale permission data
|
||||
WHEN:
|
||||
- _fulltext_similar_documents() is called with that user
|
||||
THEN:
|
||||
- Only the still-permitted document is returned - the DB
|
||||
re-check via restrict_queryset_to_visible() must catch the
|
||||
document Tantivy's stale index still thinks is visible
|
||||
"""
|
||||
owner = UserFactory.create()
|
||||
viewer = UserFactory.create(is_superuser=False)
|
||||
source = DocumentFactory.create(
|
||||
content="shared content phrase",
|
||||
owner=owner,
|
||||
)
|
||||
permitted = DocumentFactory.create(
|
||||
content="shared content phrase",
|
||||
owner=owner,
|
||||
)
|
||||
now_private = DocumentFactory.create(
|
||||
content="shared content phrase",
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", viewer, permitted)
|
||||
assign_perm("view_document", viewer, now_private)
|
||||
fulltext_backend.add_or_update(source)
|
||||
fulltext_backend.add_or_update(permitted)
|
||||
fulltext_backend.add_or_update(now_private)
|
||||
|
||||
# Revoke access after indexing, without reindexing: the index still
|
||||
# carries viewer as a permitted viewer for `now_private`.
|
||||
remove_perm("view_document", viewer, now_private)
|
||||
|
||||
result = _fulltext_similar_documents(source, user=viewer, top_k=5)
|
||||
|
||||
assert [s["document_id"] for s in result] == [permitted.pk]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
||||
def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrieve):
|
||||
"""
|
||||
@@ -575,6 +858,7 @@ def test_get_taxonomy_context_retrieval_failure_degrades_to_no_hints(mock_retrie
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(LLM_EMBEDDING_BACKEND="huggingface")
|
||||
@patch("paperless_ai.ai_classifier.build_taxonomy_candidates")
|
||||
@patch("paperless_ai.ai_classifier.retrieve_similar_nodes")
|
||||
def test_get_taxonomy_context_candidate_building_failure_degrades_to_no_hints(
|
||||
|
||||
@@ -1188,9 +1188,7 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
||||
|
||||
nodes = indexing.retrieve_similar_nodes(a, document_ids=[b.id])
|
||||
|
||||
assert all(
|
||||
document_id == b.id for document_id in indexing._node_document_ids(nodes)
|
||||
)
|
||||
assert all(int(node.metadata["document_id"]) == b.id for node in nodes)
|
||||
|
||||
def test_excludes_self(
|
||||
self,
|
||||
@@ -1212,7 +1210,7 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
||||
|
||||
nodes = indexing.retrieve_similar_nodes(a, top_k=5)
|
||||
|
||||
assert set(indexing._node_document_ids(nodes)) == {b.id}
|
||||
assert {int(node.metadata["document_id"]) for node in nodes} == {b.id}
|
||||
|
||||
def test_excludes_self_with_multiple_chunks(
|
||||
self,
|
||||
@@ -1235,4 +1233,4 @@ class TestRetrieveSimilarNodesAgainstRealIndex:
|
||||
|
||||
nodes = indexing.retrieve_similar_nodes(a, top_k=3)
|
||||
|
||||
assert set(indexing._node_document_ids(nodes)) == {b.id}
|
||||
assert {int(node.metadata["document_id"]) for node in nodes} == {b.id}
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import ollama
|
||||
import openai
|
||||
import pytest
|
||||
from llama_index.core.llms.llm import ToolSelection
|
||||
@@ -11,6 +12,7 @@ from llama_index.core.llms.llm import ToolSelection
|
||||
from paperless_ai.client import LLM_SYSTEM_PROMPT
|
||||
from paperless_ai.client import PLACEHOLDER_API_KEY
|
||||
from paperless_ai.client import AIClient
|
||||
from paperless_ai.exceptions import LLMProviderError
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
|
||||
@@ -214,6 +216,52 @@ def test_run_llm_query_openai_timeout_raises_local_error(
|
||||
client.run_llm_query("test_prompt")
|
||||
|
||||
|
||||
def test_run_llm_query_openai_status_error_raises_provider_error(
|
||||
mock_ai_config,
|
||||
mock_openai_llm,
|
||||
):
|
||||
mock_ai_config.llm_backend = "openai-like"
|
||||
mock_ai_config.llm_model = "test_model"
|
||||
mock_ai_config.llm_endpoint = "http://test-url"
|
||||
|
||||
request = httpx.Request("POST", "http://test-url/v1/chat/completions")
|
||||
body = {"error": {"message": "Thinking mode does not support this tool_choice"}}
|
||||
mock_openai_llm.return_value.chat_with_tools.side_effect = openai.BadRequestError(
|
||||
"Error code: 400",
|
||||
response=httpx.Response(400, request=request, json=body),
|
||||
body=body,
|
||||
)
|
||||
|
||||
client = AIClient()
|
||||
|
||||
with pytest.raises(LLMProviderError) as exc_info:
|
||||
client.run_llm_query("test_prompt")
|
||||
assert str(exc_info.value) == ""
|
||||
assert isinstance(exc_info.value.__cause__, openai.BadRequestError)
|
||||
|
||||
|
||||
def test_run_llm_query_ollama_response_error_raises_provider_error(
|
||||
mock_ai_config,
|
||||
mock_ollama_llm,
|
||||
):
|
||||
mock_ai_config.llm_backend = "ollama"
|
||||
mock_ai_config.llm_model = "test_model"
|
||||
mock_ai_config.llm_endpoint = "http://test-url"
|
||||
|
||||
response_error = ollama.ResponseError(
|
||||
"confidential provider response",
|
||||
status_code=400,
|
||||
)
|
||||
mock_ollama_llm.return_value.chat.side_effect = response_error
|
||||
|
||||
client = AIClient()
|
||||
|
||||
with pytest.raises(LLMProviderError) as exc_info:
|
||||
client.run_llm_query("test_prompt")
|
||||
assert str(exc_info.value) == ""
|
||||
assert exc_info.value.__cause__ is response_error
|
||||
|
||||
|
||||
def test_run_llm_query_httpx_timeout_raises_local_error(
|
||||
mock_ai_config,
|
||||
mock_ollama_llm,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
@@ -10,14 +9,14 @@ from documents.tests.factories import DocumentTypeFactory
|
||||
from documents.tests.factories import StoragePathFactory
|
||||
from documents.tests.factories import TagFactory
|
||||
from documents.tests.factories import UserFactory
|
||||
from paperless_ai.taxonomy import SimilarDocument
|
||||
from paperless_ai.taxonomy import TaxonomyCandidates
|
||||
from paperless_ai.taxonomy import build_taxonomy_candidates
|
||||
from paperless_ai.taxonomy import format_taxonomy_for_prompt
|
||||
|
||||
|
||||
def make_node(document_id: int, score: float) -> SimpleNamespace:
|
||||
"""A stand-in for NodeWithScore: only ``.metadata``/``.score`` are read."""
|
||||
return SimpleNamespace(metadata={"document_id": str(document_id)}, score=score)
|
||||
def make_similar(document_id: int, weight: float) -> SimilarDocument:
|
||||
return SimilarDocument(document_id=document_id, weight=weight)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -53,9 +52,9 @@ class TestBuildTaxonomyCandidates:
|
||||
doc_a.tags.add(tag)
|
||||
doc_b = DocumentFactory.create()
|
||||
doc_b.tags.add(tag)
|
||||
nodes = [make_node(doc_a.pk, 0.9), make_node(doc_b.pk, 0.4)]
|
||||
similar_documents = [make_similar(doc_a.pk, 0.9), make_similar(doc_b.pk, 0.4)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["tags"]) == 1
|
||||
assert result["tags"][0]["id"] == tag.pk
|
||||
@@ -80,9 +79,9 @@ class TestBuildTaxonomyCandidates:
|
||||
document.tags.add(tag)
|
||||
tag.name = "New Name"
|
||||
tag.save()
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert result["tags"][0]["name"] == "New Name"
|
||||
|
||||
@@ -102,9 +101,9 @@ class TestBuildTaxonomyCandidates:
|
||||
document = DocumentFactory.create()
|
||||
document.tags.add(tag)
|
||||
tag.delete()
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert result["tags"] == []
|
||||
|
||||
@@ -123,9 +122,12 @@ class TestBuildTaxonomyCandidates:
|
||||
strong_doc.tags.add(strong_tag)
|
||||
weak_doc = DocumentFactory.create()
|
||||
weak_doc.tags.add(weak_tag)
|
||||
nodes = [make_node(strong_doc.pk, 0.9), make_node(weak_doc.pk, 0.1)]
|
||||
similar_documents = [
|
||||
make_similar(strong_doc.pk, 0.9),
|
||||
make_similar(weak_doc.pk, 0.1),
|
||||
]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert [c["name"] for c in result["tags"]] == ["Strong", "Weak"]
|
||||
|
||||
@@ -141,9 +143,9 @@ class TestBuildTaxonomyCandidates:
|
||||
document = DocumentFactory.create()
|
||||
for i in range(15):
|
||||
document.tags.add(TagFactory.create(name=f"Tag{i}"))
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["tags"]) == 10
|
||||
|
||||
@@ -157,12 +159,12 @@ class TestBuildTaxonomyCandidates:
|
||||
- Only 5 correspondents are returned
|
||||
"""
|
||||
correspondents = CorrespondentFactory.create_batch(7)
|
||||
nodes = [
|
||||
make_node(DocumentFactory.create(correspondent=c).pk, 0.5)
|
||||
similar_documents = [
|
||||
make_similar(DocumentFactory.create(correspondent=c).pk, 0.5)
|
||||
for c in correspondents
|
||||
]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["correspondents"]) == 5
|
||||
|
||||
@@ -177,9 +179,9 @@ class TestBuildTaxonomyCandidates:
|
||||
"""
|
||||
document_type = DocumentTypeFactory.create(name="Invoice")
|
||||
document = DocumentFactory.create(document_type=document_type)
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["document_types"]) == 1
|
||||
assert result["document_types"][0]["id"] == document_type.pk
|
||||
@@ -195,12 +197,12 @@ class TestBuildTaxonomyCandidates:
|
||||
- Only 5 document_types are returned
|
||||
"""
|
||||
document_types = DocumentTypeFactory.create_batch(7)
|
||||
nodes = [
|
||||
make_node(DocumentFactory.create(document_type=dt).pk, 0.5)
|
||||
similar_documents = [
|
||||
make_similar(DocumentFactory.create(document_type=dt).pk, 0.5)
|
||||
for dt in document_types
|
||||
]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["document_types"]) == 5
|
||||
|
||||
@@ -215,9 +217,9 @@ class TestBuildTaxonomyCandidates:
|
||||
"""
|
||||
storage_path = StoragePathFactory.create(name="Invoices")
|
||||
document = DocumentFactory.create(storage_path=storage_path)
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["storage_paths"]) == 1
|
||||
assert result["storage_paths"][0]["id"] == storage_path.pk
|
||||
@@ -233,12 +235,12 @@ class TestBuildTaxonomyCandidates:
|
||||
- Only 5 storage_paths are returned
|
||||
"""
|
||||
storage_paths = StoragePathFactory.create_batch(7)
|
||||
nodes = [
|
||||
make_node(DocumentFactory.create(storage_path=sp).pk, 0.5)
|
||||
similar_documents = [
|
||||
make_similar(DocumentFactory.create(storage_path=sp).pk, 0.5)
|
||||
for sp in storage_paths
|
||||
]
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert len(result["storage_paths"]) == 5
|
||||
|
||||
@@ -258,14 +260,14 @@ class TestBuildTaxonomyCandidates:
|
||||
tag = TagFactory.create(name="Restricted")
|
||||
document = DocumentFactory.create()
|
||||
document.tags.add(tag)
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
user = UserFactory.create()
|
||||
mocker.patch(
|
||||
"documents.permissions.permitted_object_ids",
|
||||
return_value=[], # user cannot see this tag
|
||||
)
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=user)
|
||||
result = build_taxonomy_candidates(similar_documents, user=user)
|
||||
|
||||
assert result["tags"] == []
|
||||
|
||||
@@ -295,10 +297,10 @@ class TestBuildTaxonomyCandidates:
|
||||
tag.save()
|
||||
document = DocumentFactory.create()
|
||||
document.tags.add(tag)
|
||||
nodes = [make_node(document.pk, 0.5)]
|
||||
similar_documents = [make_similar(document.pk, 0.5)]
|
||||
spy = mocker.patch("documents.permissions.permitted_object_ids")
|
||||
|
||||
result = build_taxonomy_candidates(nodes, user=None)
|
||||
result = build_taxonomy_candidates(similar_documents, user=None)
|
||||
|
||||
assert result["tags"][0]["name"] == "Owned"
|
||||
spy.assert_not_called()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
|
||||
from celery import Task
|
||||
from celery import shared_task
|
||||
|
||||
from documents.models import PaperlessTask
|
||||
from paperless_mail.mail import MailAccountHandler
|
||||
from paperless_mail.mail import MailError
|
||||
from paperless_mail.models import MailAccount
|
||||
@@ -10,8 +12,26 @@ from paperless_mail.models import MailRule
|
||||
logger = logging.getLogger("paperless.mail.tasks")
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_mail_accounts(account_ids: list[int] | None = None) -> str:
|
||||
@shared_task(bind=True)
|
||||
def process_mail_accounts(self: Task, account_ids: list[int] | None = None) -> str:
|
||||
# A scheduled check can still be running (or queued) when the next one
|
||||
# ProcessedMail dedup only records a message once its
|
||||
# handling has finished, so an overlapping run can still pick up the same
|
||||
# not-yet-recorded message. Skip outright rather than race it.
|
||||
other_mail_fetch_running = (
|
||||
PaperlessTask.objects.filter(
|
||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
||||
status__in=[PaperlessTask.Status.PENDING, PaperlessTask.Status.STARTED],
|
||||
)
|
||||
.exclude(task_id=self.request.id)
|
||||
.exists()
|
||||
)
|
||||
if other_mail_fetch_running:
|
||||
logger.info(
|
||||
"Mail account processing is already running; skipping this run.",
|
||||
)
|
||||
return "Skipped: mail account processing already in progress."
|
||||
|
||||
total_new_documents = 0
|
||||
accounts = (
|
||||
MailAccount.objects.filter(pk__in=account_ids)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
|
||||
from documents.models import PaperlessTask
|
||||
from documents.tests.factories import PaperlessTaskFactory
|
||||
from paperless_mail import tasks
|
||||
from paperless_mail.tests.factories import MailAccountFactory
|
||||
from paperless_mail.tests.factories import MailRuleFactory
|
||||
|
||||
NO_DOCUMENTS_ADDED: Final = "No new documents were added."
|
||||
SKIPPED: Final = "Skipped: mail account processing already in progress."
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("account_with_rule")
|
||||
class TestProcessMailAccountsOverlap:
|
||||
@pytest.fixture
|
||||
def account_with_rule(self) -> None:
|
||||
"""An enabled mail account with a single enabled rule."""
|
||||
account = MailAccountFactory.create()
|
||||
MailRuleFactory.create(account=account, enabled=True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected_result", "expected_call_count"),
|
||||
[
|
||||
pytest.param(
|
||||
PaperlessTask.Status.PENDING,
|
||||
SKIPPED,
|
||||
0,
|
||||
id="pending-task-blocks",
|
||||
),
|
||||
pytest.param(
|
||||
PaperlessTask.Status.STARTED,
|
||||
SKIPPED,
|
||||
0,
|
||||
id="started-task-blocks",
|
||||
),
|
||||
pytest.param(
|
||||
PaperlessTask.Status.SUCCESS,
|
||||
NO_DOCUMENTS_ADDED,
|
||||
1,
|
||||
id="finished-task-does-not-block",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_skips_only_while_another_mail_fetch_task_runs(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
status: PaperlessTask.Status,
|
||||
expected_result: str,
|
||||
expected_call_count: int,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An enabled mail account with a rule
|
||||
- Another mail fetch task row in the given status
|
||||
WHEN:
|
||||
- Mail accounts are processed
|
||||
THEN:
|
||||
- Processing is skipped only if that other task is pending or running
|
||||
"""
|
||||
PaperlessTaskFactory.create(
|
||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
||||
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
|
||||
status=status,
|
||||
)
|
||||
|
||||
mocked_handle = mocker.patch.object(
|
||||
tasks.MailAccountHandler,
|
||||
"handle_mail_account",
|
||||
return_value=0,
|
||||
)
|
||||
|
||||
result = tasks.process_mail_accounts()
|
||||
|
||||
assert mocked_handle.call_count == expected_call_count
|
||||
assert result == expected_result
|
||||
|
||||
def test_runs_when_no_other_mail_fetch_task_exists(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An enabled mail account with a rule
|
||||
- No other mail fetch task rows
|
||||
WHEN:
|
||||
- Mail accounts are processed
|
||||
THEN:
|
||||
- The account is handled
|
||||
"""
|
||||
mocked_handle = mocker.patch.object(
|
||||
tasks.MailAccountHandler,
|
||||
"handle_mail_account",
|
||||
return_value=0,
|
||||
)
|
||||
|
||||
result = tasks.process_mail_accounts()
|
||||
|
||||
mocked_handle.assert_called_once()
|
||||
assert result == NO_DOCUMENTS_ADDED
|
||||
|
||||
def test_does_not_skip_due_to_its_own_task_row(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An enabled mail account with a rule
|
||||
- A running mail fetch task row belonging to this very task
|
||||
WHEN:
|
||||
- Mail accounts are processed under that task id
|
||||
THEN:
|
||||
- The task does not skip itself and handles the account
|
||||
"""
|
||||
PaperlessTaskFactory.create(
|
||||
task_id="self-task-id",
|
||||
task_type=PaperlessTask.TaskType.MAIL_FETCH,
|
||||
trigger_source=PaperlessTask.TriggerSource.SCHEDULED,
|
||||
status=PaperlessTask.Status.STARTED,
|
||||
)
|
||||
|
||||
mocked_handle = mocker.patch.object(
|
||||
tasks.MailAccountHandler,
|
||||
"handle_mail_account",
|
||||
return_value=0,
|
||||
)
|
||||
|
||||
result = tasks.process_mail_accounts.apply(task_id="self-task-id").result
|
||||
|
||||
mocked_handle.assert_called_once()
|
||||
assert result == NO_DOCUMENTS_ADDED
|
||||
Reference in New Issue
Block a user