mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-14 21:58:00 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20ec83be8 | ||
|
|
cb2506900e | ||
|
|
5293194551 | ||
|
|
1b86488e2e | ||
|
|
c9a5607902 | ||
|
|
05b7697c35 | ||
|
|
c626ecd9bc | ||
|
|
aeed83b14a | ||
|
|
72ea38ab12 | ||
|
|
4421d4fe58 | ||
|
|
4d64632f70 | ||
|
|
26094bc863 | ||
|
|
9dbad4de09 | ||
|
|
4a54935b3d | ||
|
|
d53c9070ba | ||
|
|
f885833a38 | ||
|
|
197c80ea68 | ||
|
|
a0c9500b6a | ||
|
|
df8e95cbd4 | ||
|
|
95944a553d | ||
|
|
2256cb3d38 | ||
|
|
9a8163fbbb | ||
|
|
d5f9605daf | ||
|
|
8593f84cae | ||
|
|
ca512af5ec | ||
|
|
a00755907e | ||
|
|
abdf15466c | ||
|
|
54a6f0fd2b | ||
|
|
2d64684043 | ||
|
|
60709b8319 | ||
|
|
1c96819625 | ||
|
|
3e56dace73 | ||
|
|
310628699d | ||
|
|
aff0f9cf41 | ||
|
|
bf716ebfd1 | ||
|
|
7d67a10a35 | ||
|
|
8d1bc5dd24 | ||
|
|
43a8d7d412 | ||
|
|
c40922440b |
@@ -0,0 +1,58 @@
|
|||||||
|
#!/command/with-contenv /usr/bin/bash
|
||||||
|
# shellcheck shell=bash
|
||||||
|
declare -r log_prefix="[init-compile-bytecode]"
|
||||||
|
|
||||||
|
# PYTHONDONTWRITEBYTECODE=1 is set for the whole container. This unit compiles a
|
||||||
|
# scoped set of libraries anyway, to speed up startup without bloating image size.
|
||||||
|
|
||||||
|
# Handle the people using a read only file system
|
||||||
|
if [[ "${S6_READ_ONLY_ROOT}" == "1" ]]; then
|
||||||
|
echo "${log_prefix} S6_READ_ONLY_ROOT=1, skipping (nothing to write bytecode to)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# When running as a non-root user, site-packages is still root-owned and unwritable,
|
||||||
|
# so this step would just fail loudly on every container start. Skip it.
|
||||||
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
|
echo "${log_prefix} USER_IS_NON_ROOT is set, skipping (site-packages is not writable)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -r site_packages="$(python3 -c 'import site; print(site.getsitepackages()[0])')"
|
||||||
|
|
||||||
|
# Deliberately scoped to packages that paperless.settings/paperless/__init__.py import
|
||||||
|
# unconditionally on every manage.py invocation (Django itself, the always-loaded
|
||||||
|
# INSTALLED_APPS, and celery). This is NOT "compile everything" - the optional AI stack
|
||||||
|
# (torch, llama-index, sentence-transformers, ...) is intentionally excluded since it is
|
||||||
|
# lazy-imported and large.
|
||||||
|
declare -a scope=(
|
||||||
|
"${PAPERLESS_SRC_DIR}"
|
||||||
|
"${site_packages}/django"
|
||||||
|
"${site_packages}/celery"
|
||||||
|
"${site_packages}/kombu"
|
||||||
|
"${site_packages}/rest_framework"
|
||||||
|
"${site_packages}/django_filters"
|
||||||
|
"${site_packages}/whitenoise"
|
||||||
|
"${site_packages}/corsheaders"
|
||||||
|
"${site_packages}/django_extensions"
|
||||||
|
"${site_packages}/guardian"
|
||||||
|
"${site_packages}/allauth"
|
||||||
|
"${site_packages}/drf_spectacular"
|
||||||
|
"${site_packages}/drf_spectacular_sidecar"
|
||||||
|
"${site_packages}/treenode"
|
||||||
|
"${site_packages}/compression_middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
declare -a existing_scope=()
|
||||||
|
for path in "${scope[@]}"; do
|
||||||
|
[[ -d "${path}" ]] && existing_scope+=("${path}")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "${log_prefix} Compiling bytecode for: ${existing_scope[*]}"
|
||||||
|
declare -r start_seconds=${SECONDS}
|
||||||
|
|
||||||
|
if ! PYTHONDONTWRITEBYTECODE= python3 -m compileall -q "${existing_scope[@]}"; then
|
||||||
|
echo "${log_prefix} WARNING: compileall reported errors (read-only filesystem or unwritable site-packages?); continuing without a bytecode cache"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${log_prefix} Done in $((SECONDS - start_seconds))s"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
oneshot
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run
|
||||||
@@ -1200,6 +1200,23 @@ still perform some basic text pre-processing before matching.
|
|||||||
|
|
||||||
Defaults to true, enabling the feature.
|
Defaults to true, enabling the feature.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=<float>`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD}
|
||||||
|
|
||||||
|
: Sets the minimum confidence score (0.0-1.0) required for the automatic
|
||||||
|
classifier to assign a correspondent, document type, or storage path to a
|
||||||
|
document. Predictions below this threshold are discarded and the field is
|
||||||
|
left unassigned, preventing low-confidence guesses from being applied.
|
||||||
|
|
||||||
|
Defaults to 0.6.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS=<float>`](#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS) {#PAPERLESS_MATCH_REGEX_TIMEOUT_SECONDS}
|
||||||
|
|
||||||
|
: Sets the timeout, in seconds, for regular expression matching. Increase this
|
||||||
|
value if date parsing or user-defined matching rules time out when processing
|
||||||
|
long documents, especially on slower hardware.
|
||||||
|
|
||||||
|
Defaults to 0.1 seconds.
|
||||||
|
|
||||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||||
|
|
||||||
: Specifies which language Paperless should use when parsing dates from documents.
|
: Specifies which language Paperless should use when parsing dates from documents.
|
||||||
|
|||||||
+385
-299
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,22 @@
|
|||||||
|
|
||||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
@@ -209,6 +209,45 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
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 () => {
|
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
@@ -249,6 +288,7 @@ describe('SettingsComponent', () => {
|
|||||||
|
|
||||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
|
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||||
@@ -267,7 +307,10 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
expect(setSpy).toHaveBeenCalledTimes(34)
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
|
HideableSidebarItemID.Workflows,
|
||||||
|
])
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import {
|
||||||
|
GlobalSearchType,
|
||||||
|
HIDEABLE_SIDEBAR_ITEM_IDS,
|
||||||
|
HideableSidebarItemID,
|
||||||
|
SETTINGS_KEYS,
|
||||||
|
} from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -102,6 +107,14 @@ const documentDetailFieldOptions = [
|
|||||||
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
{ 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({
|
@Component({
|
||||||
selector: 'pngx-settings',
|
selector: 'pngx-settings',
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
@@ -149,6 +162,7 @@ export class SettingsComponent
|
|||||||
bulkEditApplyOnClose: new FormControl(null),
|
bulkEditApplyOnClose: new FormControl(null),
|
||||||
documentListItemPerPage: new FormControl(null),
|
documentListItemPerPage: new FormControl(null),
|
||||||
slimSidebarEnabled: new FormControl(null),
|
slimSidebarEnabled: new FormControl(null),
|
||||||
|
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
|
||||||
darkModeUseSystem: new FormControl(null),
|
darkModeUseSystem: new FormControl(null),
|
||||||
darkModeEnabled: new FormControl(null),
|
darkModeEnabled: new FormControl(null),
|
||||||
darkModeInvertThumbs: new FormControl(null),
|
darkModeInvertThumbs: new FormControl(null),
|
||||||
@@ -186,6 +200,7 @@ export class SettingsComponent
|
|||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
storeSub: Subscription
|
storeSub: Subscription
|
||||||
|
sidebarItemsSub: Subscription
|
||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
isDirty: boolean = false
|
isDirty: boolean = false
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
@@ -203,6 +218,10 @@ export class SettingsComponent
|
|||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
|
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
||||||
|
id,
|
||||||
|
label: sidebarItemLabels[id],
|
||||||
|
}))
|
||||||
|
|
||||||
get systemStatusHasErrors(): boolean {
|
get systemStatusHasErrors(): boolean {
|
||||||
const status = this.systemStatus()
|
const status = this.systemStatus()
|
||||||
@@ -230,6 +249,10 @@ export class SettingsComponent
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
this.sidebarItemsSub =
|
||||||
|
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
|
||||||
|
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
|
||||||
|
)
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
if (!this.savePending) this.initialize()
|
if (!this.savePending) this.initialize()
|
||||||
this.savedViewsService.maybeRefreshDocumentCounts()
|
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||||
@@ -279,14 +302,21 @@ export class SettingsComponent
|
|||||||
|
|
||||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||||
const section = paramMap.get('section')
|
const section = paramMap.get('section')
|
||||||
|
let navID = SettingsNavIDs.General
|
||||||
if (section) {
|
if (section) {
|
||||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
navID = SettingsNavIDs[navIDKey]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.activeNavID.set(navID)
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set(
|
||||||
|
navID === SettingsNavIDs.General
|
||||||
|
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
||||||
|
: null
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +340,7 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
||||||
),
|
),
|
||||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
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),
|
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||||
darkModeInvertThumbs: this.settings.get(
|
darkModeInvertThumbs: this.settings.get(
|
||||||
@@ -436,6 +467,12 @@ export class SettingsComponent
|
|||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.settings.organizingSidebarItems()) {
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set([
|
||||||
|
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
if (this.canViewSystemStatus) {
|
if (this.canViewSystemStatus) {
|
||||||
this.systemStatusService.get().subscribe((status) => {
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
this.systemStatus.set(status)
|
this.systemStatus.set(status)
|
||||||
@@ -444,8 +481,18 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set(null)
|
||||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||||
this.storeSub && this.storeSub.unsubscribe()
|
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() {
|
public saveSettings() {
|
||||||
@@ -473,6 +520,10 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||||
this.settingsForm.value.slimSidebarEnabled
|
this.settingsForm.value.slimSidebarEnabled
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||||
|
this.settingsForm.value.sidebarHiddenItems
|
||||||
|
)
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||||
this.settingsForm.value.darkModeUseSystem
|
this.settingsForm.value.darkModeUseSystem
|
||||||
@@ -632,6 +683,11 @@ export class SettingsComponent
|
|||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.settingsForm.patchValue(this.store.getValue())
|
this.settingsForm.patchValue(this.store.getValue())
|
||||||
|
if (this.settings.organizingSidebarItems()) {
|
||||||
|
this.settings.sidebarHiddenItemsEditing.set([
|
||||||
|
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const TASK_TYPE_OPTIONS: Array<{
|
|||||||
value: PaperlessTaskType.BulkDelete,
|
value: PaperlessTaskType.BulkDelete,
|
||||||
label: $localize`Bulk Delete`,
|
label: $localize`Bulk Delete`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: PaperlessTaskType.ApplyAiSuggestions,
|
||||||
|
label: $localize`Apply AI Suggestions`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const TRIGGER_SOURCE_OPTIONS: Array<{
|
const TRIGGER_SOURCE_OPTIONS: Array<{
|
||||||
|
|||||||
@@ -86,12 +86,15 @@
|
|||||||
}
|
}
|
||||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</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>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
@@ -237,29 +240,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<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" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</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>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</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>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</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>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
@@ -322,13 +334,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<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"
|
<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()"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
|
@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>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="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 { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import {
|
import {
|
||||||
@@ -287,6 +287,82 @@ describe('AppFrameComponent', () => {
|
|||||||
jest.useRealTimers()
|
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', () => {
|
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@@ -21,7 +22,11 @@ import { Observable } from 'rxjs'
|
|||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import {
|
||||||
|
CollapsibleSection,
|
||||||
|
HideableSidebarItemID,
|
||||||
|
SETTINGS_KEYS,
|
||||||
|
} from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -48,6 +53,7 @@ import { ChatComponent } from '../chat/chat/chat.component'
|
|||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.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 { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -76,6 +82,8 @@ const SCROLL_THRESHOLD = 16
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
|
FormsModule,
|
||||||
|
SwitchComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -98,6 +106,7 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
|
readonly HideableSidebarItemID = HideableSidebarItemID
|
||||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||||
SETTINGS_KEYS.VERSION
|
SETTINGS_KEYS.VERSION
|
||||||
)
|
)
|
||||||
@@ -195,6 +204,10 @@ export class AppFrameComponent
|
|||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
|
||||||
|
this.settingsService.updateSidebarItemVisibility(item, visible)
|
||||||
|
}
|
||||||
|
|
||||||
toggleAttributesSections(event?: Event): void {
|
toggleAttributesSections(event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="mb-3">
|
<div [class.mb-3]="!compact">
|
||||||
<div class="row">
|
<div [class.row]="!compact">
|
||||||
@if (!horizontal) {
|
@if (!horizontal && !compact) {
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
<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">
|
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
}
|
}
|
||||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||||
<div class="form-check form-switch">
|
<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">
|
<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) {
|
@if (horizontal && !compact) {
|
||||||
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@if (showUnsetNote && isUnset) {
|
@if (showUnsetNote && isUnset) {
|
||||||
|
|||||||
@@ -48,4 +48,14 @@ describe('SwitchComponent', () => {
|
|||||||
component.value = undefined
|
component.value = undefined
|
||||||
expect(component.isUnset).toBeTruthy()
|
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,6 +25,9 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
|||||||
@Input()
|
@Input()
|
||||||
showUnsetNote: boolean = false
|
showUnsetNote: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
compact: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ import { Subject, of, throwError } from 'rxjs'
|
|||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
|
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
|
||||||
import { DataType } from 'src/app/data/datatype'
|
import { DataType } from 'src/app/data/datatype'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document, DocumentVersionInfo } from 'src/app/data/document'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
import {
|
import {
|
||||||
FILTER_CORRESPONDENT,
|
FILTER_CORRESPONDENT,
|
||||||
@@ -100,13 +101,18 @@ const doc: Document = {
|
|||||||
custom_fields: [
|
custom_fields: [
|
||||||
{
|
{
|
||||||
field: 0,
|
field: 0,
|
||||||
document: 3,
|
|
||||||
created: new Date(),
|
|
||||||
value: 'custom foo bar',
|
value: 'custom foo bar',
|
||||||
},
|
},
|
||||||
],
|
] as CustomFieldInstance[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Newest first, as the API returns them: 12 is the latest, 3 is the root
|
||||||
|
const docVersions: DocumentVersionInfo[] = [
|
||||||
|
{ id: 12, is_root: false },
|
||||||
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
|
]
|
||||||
|
|
||||||
const customFields = [
|
const customFields = [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -2045,6 +2051,208 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(saveSpy).toHaveBeenCalled()
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('selectVersion should use the version content as the baseline and ignore stale responses', () => {
|
||||||
|
initNormally()
|
||||||
|
const version10Content = new Subject<Document>()
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValueOnce(version10Content)
|
||||||
|
.mockReturnValueOnce(of({ content: 'version 12 content' } as Document))
|
||||||
|
const version10Metadata = new Subject<any>()
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'getMetadata')
|
||||||
|
.mockReturnValueOnce(version10Metadata)
|
||||||
|
.mockReturnValueOnce(of({ lang: 'de' }))
|
||||||
|
|
||||||
|
component.selectVersion(10)
|
||||||
|
component.selectVersion(12)
|
||||||
|
version10Content.next({ content: 'version 10 content' } as Document)
|
||||||
|
version10Metadata.next({ lang: 'en' })
|
||||||
|
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
expect(component.store.value.content).toEqual('version 12 content')
|
||||||
|
expect(component.metadata().lang).toEqual('de')
|
||||||
|
expect(
|
||||||
|
httpTestingController.expectOne(component.previewUrl()).cancelled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
httpTestingController.match((req) => req.url.includes('version=10'))[0]
|
||||||
|
?.cancelled
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm before discarding unsaved content edits when switching versions', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockImplementation((id, versionID) =>
|
||||||
|
of({ content: `version ${versionID} content` } as Document)
|
||||||
|
)
|
||||||
|
let openModal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
// shared fields carry over between versions, so no confirmation
|
||||||
|
component.documentForm.get('title').setValue('Edited title')
|
||||||
|
component.documentForm.get('title').markAsDirty()
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.onVersionSelected(12)
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.onVersionSelected(12) // already selected, nothing to do
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(
|
||||||
|
ConfirmDialogComponent,
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
openModal.componentInstance.cancel()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'edited content'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
openModal.componentInstance.confirmClicked.emit()
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 10 content'
|
||||||
|
)
|
||||||
|
expect(component.documentForm.get('content').dirty).toBeFalsy()
|
||||||
|
expect(component.documentForm.get('title').value).toEqual('Edited title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save unsaved content edits to the current version before switching, and stay if that fails', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
component.selectedVersionId.set(12)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValue(of({ content: 'version 10 content' } as Document))
|
||||||
|
const savedDoc = new Subject<Document>()
|
||||||
|
const patchSpy = jest
|
||||||
|
.spyOn(documentService, 'patch')
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('failed to save')))
|
||||||
|
.mockReturnValueOnce(savedDoc)
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
let modal: NgbModalRef = modalSpy.mock.results[0].value
|
||||||
|
const closeSpy = jest.spyOn(modal, 'close')
|
||||||
|
modal.componentInstance.alternativeClicked.emit()
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'edited content'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.onVersionSelected(10)
|
||||||
|
modal = modalSpy.mock.results[1].value
|
||||||
|
modal.componentInstance.alternativeClicked.emit()
|
||||||
|
expect(patchSpy).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ content: 'edited content' }),
|
||||||
|
12
|
||||||
|
)
|
||||||
|
component.onVersionSelected(doc.id) // ignored while saving
|
||||||
|
expect(modalSpy).toHaveBeenCalledTimes(2)
|
||||||
|
savedDoc.next(doc)
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 10 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch without confirmation when the selected version was deleted, even while saving', () => {
|
||||||
|
initNormally()
|
||||||
|
component.document().versions = docVersions
|
||||||
|
component.selectedVersionId.set(10)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValue(of({ content: 'version 12 content' } as Document))
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
component.documentForm.get('content').setValue('edited content')
|
||||||
|
component.documentForm.get('content').markAsDirty()
|
||||||
|
component.networkActive.set(true)
|
||||||
|
|
||||||
|
// the version dropdown emits this after deleting the selected version
|
||||||
|
component.onVersionsUpdated(docVersions.filter((v) => v.id !== 10))
|
||||||
|
component.onVersionSelected(12)
|
||||||
|
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore the selected version and its unsaved content when returning to a document', () => {
|
||||||
|
initNormally()
|
||||||
|
const openDoc = component.document()
|
||||||
|
openDoc.versions = docVersions
|
||||||
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockImplementation((id, versionID) =>
|
||||||
|
of(
|
||||||
|
(versionID
|
||||||
|
? { content: `version ${versionID} content` }
|
||||||
|
: { ...doc, versions: docVersions }) as Document
|
||||||
|
)
|
||||||
|
)
|
||||||
|
component.selectVersion(10)
|
||||||
|
// an edit that happens to match the latest version's content
|
||||||
|
component.documentForm.get('content').setValue(doc.content)
|
||||||
|
openDoc.__changedFields = ['content']
|
||||||
|
|
||||||
|
component['loadDocument'](doc.id)
|
||||||
|
|
||||||
|
expect(component.selectedVersionId()).toEqual(10)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(doc.content)
|
||||||
|
expect(openDocumentsService.isDirty(openDoc)).toBeTruthy()
|
||||||
|
const patchSpy = jest
|
||||||
|
.spyOn(documentService, 'patch')
|
||||||
|
.mockReturnValue(of(doc))
|
||||||
|
component.save()
|
||||||
|
expect(patchSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ content: doc.content }),
|
||||||
|
10
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to the latest version when the remembered version no longer exists', () => {
|
||||||
|
initNormally()
|
||||||
|
const openDoc = component.document()
|
||||||
|
openDoc.versions = docVersions
|
||||||
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
|
jest.spyOn(documentService, 'get').mockImplementation((id, versionID) =>
|
||||||
|
of(
|
||||||
|
(versionID
|
||||||
|
? { content: `version ${versionID} content` }
|
||||||
|
: {
|
||||||
|
...doc,
|
||||||
|
content: 'version 12 content',
|
||||||
|
versions: docVersions.filter((v) => v.id !== 10),
|
||||||
|
}) as Document
|
||||||
|
)
|
||||||
|
)
|
||||||
|
component.selectVersion(10)
|
||||||
|
|
||||||
|
component['loadDocument'](doc.id)
|
||||||
|
|
||||||
|
expect(component.selectedVersionId()).toEqual(12)
|
||||||
|
expect(component.documentForm.get('content').value).toEqual(
|
||||||
|
'version 12 content'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
it('createDisabled should return true if the user does not have permission to add the specified data type', () => {
|
||||||
currentUserCan = false
|
currentUserCan = false
|
||||||
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
expect(component.createDisabled(DataType.Correspondent)).toBeTruthy()
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
|||||||
import * as UTIF from 'utif'
|
import * as UTIF from 'utif'
|
||||||
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
|
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -304,6 +304,7 @@ export class DocumentDetailComponent
|
|||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
docChangeNotifier: Subject<any> = new Subject()
|
docChangeNotifier: Subject<any> = new Subject()
|
||||||
|
versionChangeNotifier: Subject<void> = new Subject()
|
||||||
private incomingUpdateModal: NgbModalRef
|
private incomingUpdateModal: NgbModalRef
|
||||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||||
private lastLocalSaveModified: string | null = null
|
private lastLocalSaveModified: string | null = null
|
||||||
@@ -417,7 +418,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
@@ -533,7 +535,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (res) => this.previewText.set(res.toString()),
|
next: (res) => this.previewText.set(res.toString()),
|
||||||
@@ -595,6 +598,13 @@ export class DocumentDetailComponent
|
|||||||
openDocument.duplicate_documents = doc.duplicate_documents
|
openDocument.duplicate_documents = doc.duplicate_documents
|
||||||
this.openDocumentService.save()
|
this.openDocumentService.save()
|
||||||
}
|
}
|
||||||
|
// use server versions
|
||||||
|
if (openDocument) {
|
||||||
|
openDocument.versions = doc.versions
|
||||||
|
if (!openDocument.__changedFields?.includes('content')) {
|
||||||
|
openDocument.content = doc.content
|
||||||
|
}
|
||||||
|
}
|
||||||
let useDoc = openDocument || doc
|
let useDoc = openDocument || doc
|
||||||
if (openDocument && forceRemote) {
|
if (openDocument && forceRemote) {
|
||||||
Object.assign(openDocument, doc)
|
Object.assign(openDocument, doc)
|
||||||
@@ -642,7 +652,14 @@ export class DocumentDetailComponent
|
|||||||
this.documentForm.patchValue({ title: titleValue })
|
this.documentForm.patchValue({ title: titleValue })
|
||||||
this.documentForm.get('title').markAsDirty()
|
this.documentForm.get('title').markAsDirty()
|
||||||
})
|
})
|
||||||
|
const keepContentEdits =
|
||||||
|
useDoc.__selectedVersionId === this.selectedVersionId() &&
|
||||||
|
!!useDoc.__changedFields?.includes('content')
|
||||||
this.setupDirtyTracking(useDoc, doc)
|
this.setupDirtyTracking(useDoc, doc)
|
||||||
|
// Maybe load the stored version
|
||||||
|
if (useDoc.__selectedVersionId) {
|
||||||
|
this.selectVersion(this.selectedVersionId(), keepContentEdits)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -903,9 +920,11 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
updateComponent(doc: Document) {
|
updateComponent(doc: Document) {
|
||||||
this.document.set(doc)
|
this.document.set(doc)
|
||||||
// Default selected version is the newest version, which the API returns first
|
// Load the selected version, or default to API first (newest)
|
||||||
const versions = doc.versions ?? []
|
const versions = doc.versions ?? []
|
||||||
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
const selectedVersion =
|
||||||
|
versions.find((v) => v.id === doc.__selectedVersionId) ?? versions[0]
|
||||||
|
this.selectedVersionId.set(selectedVersion?.id ?? doc.id)
|
||||||
this.previewLoaded.set(false)
|
this.previewLoaded.set(false)
|
||||||
this.requiresPassword = false
|
this.requiresPassword = false
|
||||||
this.updateFormForCustomFields()
|
this.updateFormForCustomFields()
|
||||||
@@ -940,8 +959,12 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update file preview and download target to a specific version (by document id)
|
// Update file preview and download target to a specific version (by document id)
|
||||||
selectVersion(versionId: number) {
|
selectVersion(versionId: number, keepContentEdits: boolean = false) {
|
||||||
|
this.versionChangeNotifier.next()
|
||||||
this.selectedVersionId.set(versionId)
|
this.selectedVersionId.set(versionId)
|
||||||
|
// remember so the version can be restored when returning to the document
|
||||||
|
this.document().__selectedVersionId = versionId
|
||||||
|
this.openDocumentService.save()
|
||||||
this.previewLoaded.set(false)
|
this.previewLoaded.set(false)
|
||||||
this.previewUrl.set(
|
this.previewUrl.set(
|
||||||
this.documentsService.getPreviewUrl(
|
this.documentsService.getPreviewUrl(
|
||||||
@@ -963,20 +986,20 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
const content = doc?.content ?? ''
|
const content = doc?.content ?? ''
|
||||||
this.document().content = content
|
if (keepContentEdits) {
|
||||||
this.documentForm.patchValue(
|
this.store.next({ ...this.store.value, content })
|
||||||
{
|
} else {
|
||||||
content,
|
// Update in-place and avoid the debounce wait
|
||||||
},
|
this.store.value.content = content
|
||||||
{
|
this.documentForm.patchValue({ content })
|
||||||
emitEvent: false,
|
this.documentForm.get('content').markAsPristine()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -991,7 +1014,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
takeUntil(this.versionChangeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (res) => this.previewText.set(res.toString()),
|
next: (res) => this.previewText.set(res.toString()),
|
||||||
@@ -1005,7 +1029,39 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onVersionSelected(versionId: number) {
|
onVersionSelected(versionId: number) {
|
||||||
this.selectVersion(versionId)
|
if (versionId === this.selectedVersionId()) return
|
||||||
|
// Bail if the selected version was just deleted.
|
||||||
|
const selectedVersionExists = this.document()?.versions?.some(
|
||||||
|
(v) => v.id === this.selectedVersionId()
|
||||||
|
)
|
||||||
|
if (this.networkActive() && selectedVersionExists) return
|
||||||
|
if (
|
||||||
|
!selectedVersionExists ||
|
||||||
|
this.documentForm.get('content').value === this.store.value.content
|
||||||
|
) {
|
||||||
|
this.selectVersion(versionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm any unsaved content changes
|
||||||
|
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Unsaved Changes`
|
||||||
|
modal.componentInstance.messageBold = $localize`You have unsaved changes to the content of this version.`
|
||||||
|
modal.componentInstance.message = $localize`Switching versions will discard them.`
|
||||||
|
modal.componentInstance.btnClass = 'btn-secondary'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Discard and switch`
|
||||||
|
modal.componentInstance.alternativeBtnClass = 'btn-primary'
|
||||||
|
modal.componentInstance.alternativeBtnCaption = $localize`Save and switch`
|
||||||
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
this.selectVersion(versionId)
|
||||||
|
})
|
||||||
|
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
this.save(false, () => this.selectVersion(versionId))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onVersionsUpdated(versions: DocumentVersionInfo[]) {
|
onVersionsUpdated(versions: DocumentVersionInfo[]) {
|
||||||
@@ -1233,7 +1289,7 @@ export class DocumentDetailComponent
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
save(close: boolean = false) {
|
save(close: boolean = false, savedCallback: () => void = null) {
|
||||||
this.networkActive.set(true)
|
this.networkActive.set(true)
|
||||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
@@ -1266,6 +1322,7 @@ export class DocumentDetailComponent
|
|||||||
this.flushPendingIncomingUpdate()
|
this.flushPendingIncomingUpdate()
|
||||||
}
|
}
|
||||||
this.savedViewService.maybeRefreshDocumentCounts()
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
|
savedCallback?.()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export interface Document extends ObjectWithPermissions {
|
|||||||
|
|
||||||
// Frontend only
|
// Frontend only
|
||||||
__changedFields?: string[]
|
__changedFields?: string[]
|
||||||
|
__selectedVersionId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentVersionInfo {
|
export interface DocumentVersionInfo {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum PaperlessTaskType {
|
|||||||
ReprocessDocument = 'reprocess_document',
|
ReprocessDocument = 'reprocess_document',
|
||||||
BuildShareLink = 'build_share_link',
|
BuildShareLink = 'build_share_link',
|
||||||
BulkDelete = 'bulk_delete',
|
BulkDelete = 'bulk_delete',
|
||||||
|
ApplyAiSuggestions = 'apply_ai_suggestions',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PaperlessTaskTriggerSource {
|
export enum PaperlessTaskTriggerSource {
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ export enum CollapsibleSection {
|
|||||||
ATTRIBUTES = 'attributes',
|
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 PAPERLESS_GREEN_HEX = '#17541f'
|
||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
@@ -56,6 +66,7 @@ export const SETTINGS_KEYS = {
|
|||||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||||
|
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
|
||||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||||
'general-settings:attributes-sections-collapsed',
|
'general-settings:attributes-sections-collapsed',
|
||||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||||
@@ -127,6 +138,11 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||||
|
type: 'array',
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
@@ -221,6 +221,25 @@ describe('OpenDocumentsService', () => {
|
|||||||
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should refresh documents in place and keep unsaved edits', () => {
|
||||||
|
const openDoc = { ...documents[0] }
|
||||||
|
subscriptions.push(openDocumentsService.openDocument(openDoc).subscribe())
|
||||||
|
openDoc.title = 'Unsaved title'
|
||||||
|
openDocumentsService.setDirty(openDoc, true, { title: openDoc.title })
|
||||||
|
|
||||||
|
openDocumentsService.refreshDocument(openDoc.id)
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/${openDoc.id}/?full_perms=true`
|
||||||
|
)
|
||||||
|
.flush({ ...documents[0], tags: [4] })
|
||||||
|
|
||||||
|
const refreshed = openDocumentsService.getOpenDocument(openDoc.id)
|
||||||
|
expect(refreshed).toBe(openDoc)
|
||||||
|
expect(refreshed.title).toEqual('Unsaved title')
|
||||||
|
expect(refreshed.tags).toEqual([4])
|
||||||
|
})
|
||||||
|
|
||||||
it('should handle error on refresh documents', () => {
|
it('should handle error on refresh documents', () => {
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
openDocumentsService.openDocument(documents[1]).subscribe()
|
openDocumentsService.openDocument(documents[1]).subscribe()
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ export class OpenDocumentsService {
|
|||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
this.documentService.get(id).subscribe({
|
this.documentService.get(id).subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
this.openDocuments[index] = doc
|
const openDoc = this.openDocuments.find((d) => d.id == id)
|
||||||
|
if (!openDoc) return
|
||||||
|
const unsavedEdits = Object.fromEntries(
|
||||||
|
(openDoc.__changedFields ?? []).map((field) => [
|
||||||
|
field,
|
||||||
|
openDoc[field],
|
||||||
|
])
|
||||||
|
)
|
||||||
|
Object.assign(openDoc, doc, unsavedEdits)
|
||||||
this.save()
|
this.save()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import { CustomFieldDataType } from '../data/custom-field'
|
|||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
import {
|
||||||
|
HideableSidebarItemID,
|
||||||
|
SETTINGS_KEYS,
|
||||||
|
UiSettings,
|
||||||
|
} from '../data/ui-settings'
|
||||||
import { PermissionsService } from './permissions.service'
|
import { PermissionsService } from './permissions.service'
|
||||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
@@ -230,6 +234,35 @@ describe('SettingsService', () => {
|
|||||||
expect(notesEnabled()).toBeFalsy()
|
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', () => {
|
it('updates setting signals when settings are reinitialized', () => {
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
|||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import {
|
import {
|
||||||
|
HideableSidebarItemID,
|
||||||
PAPERLESS_GREEN_HEX,
|
PAPERLESS_GREEN_HEX,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
@@ -313,6 +314,18 @@ export class SettingsService {
|
|||||||
readonly globalDropzoneEnabled = signal(true)
|
readonly globalDropzoneEnabled = signal(true)
|
||||||
readonly globalDropzoneActive = signal(false)
|
readonly globalDropzoneActive = signal(false)
|
||||||
readonly organizingSidebarSavedViews = 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 }>>(
|
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
||||||
DEFAULT_DISPLAY_FIELDS
|
DEFAULT_DISPLAY_FIELDS
|
||||||
@@ -749,6 +762,29 @@ export class SettingsService {
|
|||||||
return this.storeSettings()
|
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(
|
updateSavedViewsVisibility(
|
||||||
dashboardVisibleViewIds: number[],
|
dashboardVisibleViewIds: number[],
|
||||||
sidebarVisibleViewIds: number[]
|
sidebarVisibleViewIds: number[]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1037
-793
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
document_consumption_finished.connect(add_to_index)
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(run_workflows_added)
|
document_consumption_finished.connect(run_workflows_added)
|
||||||
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||||
document_updated.connect(run_workflows_updated)
|
document_updated.connect(run_workflows_updated)
|
||||||
document_updated.connect(send_websocket_document_updated)
|
document_updated.connect(send_websocket_document_updated)
|
||||||
|
|||||||
+65
-47
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -27,7 +28,7 @@ from documents.models import DocumentType
|
|||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
@@ -298,53 +299,55 @@ def modify_custom_fields(
|
|||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
# Ensure add_custom_fields is a list of tuples, supports old API
|
# Ensure add_custom_fields is a list of (int, value) tuples, supports old API
|
||||||
add_custom_fields = (
|
add_custom_fields = (
|
||||||
add_custom_fields.items()
|
[(int(field), value) for field, value in add_custom_fields.items()]
|
||||||
if isinstance(add_custom_fields, dict)
|
if isinstance(add_custom_fields, dict)
|
||||||
else [(field, None) for field in add_custom_fields]
|
else [(int(field), None) for field in add_custom_fields]
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_fields = CustomField.objects.filter(
|
# Resolved once, instead of re-querying the same field for every document
|
||||||
id__in=[int(field) for field, _ in add_custom_fields],
|
custom_fields_by_id: dict[int, CustomField] = CustomField.objects.in_bulk(
|
||||||
).distinct()
|
[field_id for field_id, _ in add_custom_fields],
|
||||||
|
)
|
||||||
|
# Passed to update_or_create() below rather than a bare id, so the FK is
|
||||||
|
# cached on the created instance and auditlog's post_save receiver does
|
||||||
|
# not reload it per row. Only needed for additions. content is deferred:
|
||||||
|
# the one field here that is both large and unused.
|
||||||
|
docs_by_id: dict[int, Document] = (
|
||||||
|
Document.objects.defer("content").in_bulk(affected_docs)
|
||||||
|
if add_custom_fields
|
||||||
|
else {}
|
||||||
|
)
|
||||||
for field_id, value in add_custom_fields:
|
for field_id, value in add_custom_fields:
|
||||||
|
custom_field = custom_fields_by_id[field_id]
|
||||||
|
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
||||||
|
custom_field.data_type
|
||||||
|
]
|
||||||
|
is_doclink = custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
for doc_id in affected_docs:
|
for doc_id in affected_docs:
|
||||||
defaults = {}
|
if is_doclink and value and doc_id in value:
|
||||||
custom_field = custom_fields.get(id=field_id)
|
# Prevent self-linking
|
||||||
if custom_field:
|
continue
|
||||||
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
|
||||||
custom_field.data_type
|
|
||||||
]
|
|
||||||
defaults[value_field] = value
|
|
||||||
if (
|
|
||||||
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
|
||||||
and value
|
|
||||||
and doc_id in value
|
|
||||||
):
|
|
||||||
# Prevent self-linking
|
|
||||||
continue
|
|
||||||
CustomFieldInstance.objects.update_or_create(
|
CustomFieldInstance.objects.update_or_create(
|
||||||
document_id=doc_id,
|
document=docs_by_id[doc_id],
|
||||||
field_id=field_id,
|
field=custom_field,
|
||||||
defaults=defaults,
|
defaults={value_field: value},
|
||||||
)
|
)
|
||||||
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
if is_doclink:
|
||||||
doc = Document.objects.get(id=doc_id)
|
reflect_doclinks(docs_by_id[doc_id], custom_field, value)
|
||||||
reflect_doclinks(doc, custom_field, value)
|
|
||||||
|
|
||||||
# For doc link fields that are being removed, remove symmetrical links
|
# For doc link fields that are being removed, remove symmetrical links.
|
||||||
|
# select_related avoids a per-instance reload of the document and field.
|
||||||
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
||||||
document_id__in=affected_docs,
|
document_id__in=affected_docs,
|
||||||
field__id__in=remove_custom_fields,
|
field__id__in=remove_custom_fields,
|
||||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
value_document_ids__isnull=False,
|
value_document_ids__isnull=False,
|
||||||
):
|
).select_related("field", "document"):
|
||||||
for target_doc_id in doclink_being_removed_instance.value:
|
for target_doc_id in doclink_being_removed_instance.value:
|
||||||
remove_doclink(
|
remove_doclink(
|
||||||
document=Document.objects.get(
|
document=doclink_being_removed_instance.document,
|
||||||
id=doclink_being_removed_instance.document.id,
|
|
||||||
),
|
|
||||||
field=doclink_being_removed_instance.field,
|
field=doclink_being_removed_instance.field,
|
||||||
target_doc_id=target_doc_id,
|
target_doc_id=target_doc_id,
|
||||||
)
|
)
|
||||||
@@ -379,7 +382,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
)
|
)
|
||||||
delete_ids = list({*doc_ids, *version_ids})
|
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
|
from documents.search import get_backend
|
||||||
|
|
||||||
@@ -430,10 +433,13 @@ def set_permissions(
|
|||||||
else:
|
else:
|
||||||
qs.update(owner=owner)
|
qs.update(owner=owner)
|
||||||
|
|
||||||
for doc in qs:
|
|
||||||
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
|
|
||||||
|
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
|
set_permissions_for_objects(
|
||||||
|
permissions=set_permissions,
|
||||||
|
model=Document,
|
||||||
|
pks=affected_docs,
|
||||||
|
merge=merge,
|
||||||
|
)
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
bulk_update_documents.apply_async(
|
||||||
kwargs={"document_ids": affected_docs},
|
kwargs={"document_ids": affected_docs},
|
||||||
@@ -893,17 +899,26 @@ def edit_pdf(
|
|||||||
pdf_docs: list[pikepdf.Pdf] = []
|
pdf_docs: list[pikepdf.Pdf] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if not operations:
|
||||||
|
raise ValueError("Output document index is out of bounds")
|
||||||
|
|
||||||
|
max_idx = max(op.get("doc", 0) for op in operations)
|
||||||
|
if update_document and max_idx > 0:
|
||||||
|
logger.error(
|
||||||
|
"Update requested but multiple output documents specified",
|
||||||
|
)
|
||||||
|
raise ValueError("Multiple output documents specified")
|
||||||
|
|
||||||
|
if any(
|
||||||
|
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
|
||||||
|
for op in operations
|
||||||
|
):
|
||||||
|
raise ValueError("Output document index is out of bounds")
|
||||||
|
|
||||||
with pikepdf.open(pair.source_doc.source_path) as src:
|
with pikepdf.open(pair.source_doc.source_path) as src:
|
||||||
# prepare output documents
|
# prepare output documents
|
||||||
max_idx = max(op.get("doc", 0) for op in operations)
|
|
||||||
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
|
pdf_docs = [pikepdf.new() for _ in range(max_idx + 1)]
|
||||||
|
|
||||||
if update_document and len(pdf_docs) > 1:
|
|
||||||
logger.error(
|
|
||||||
"Update requested but multiple output documents specified",
|
|
||||||
)
|
|
||||||
raise ValueError("Multiple output documents specified")
|
|
||||||
|
|
||||||
for op in operations:
|
for op in operations:
|
||||||
dst = pdf_docs[op.get("doc", 0)]
|
dst = pdf_docs[op.get("doc", 0)]
|
||||||
page = src.pages[op["page"] - 1]
|
page = src.pages[op["page"] - 1]
|
||||||
@@ -1177,10 +1192,13 @@ def remove_doclink(
|
|||||||
"""
|
"""
|
||||||
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
||||||
"""
|
"""
|
||||||
target_doc_field_instance = CustomFieldInstance.objects.filter(
|
# select_related: a signal receiver (auditlog) touches .document/.field on
|
||||||
document_id=target_doc_id,
|
# the save() below, without this that is a per-call reload query
|
||||||
field=field,
|
target_doc_field_instance = (
|
||||||
).first()
|
CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field)
|
||||||
|
.select_related("document", "field")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
target_doc_field_instance is not None
|
target_doc_field_instance is not None
|
||||||
and document.id in target_doc_field_instance.value
|
and document.id in target_doc_field_instance.value
|
||||||
|
|||||||
+66
-28
@@ -34,6 +34,27 @@ from paperless.signed_pickle import signed_pickle_loads
|
|||||||
|
|
||||||
logger = logging.getLogger("paperless.classifier")
|
logger = logging.getLogger("paperless.classifier")
|
||||||
|
|
||||||
|
|
||||||
|
def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
|
||||||
|
"""
|
||||||
|
Return the predicted class id, or None if:
|
||||||
|
- the prediction is -1 (no match), or
|
||||||
|
- the winning class probability is below the configured threshold.
|
||||||
|
|
||||||
|
Using predict_proba() instead of predict() lets us apply a minimum-confidence
|
||||||
|
cutoff so that uncertain predictions are discarded rather than assigned.
|
||||||
|
"""
|
||||||
|
probas = classifier.predict_proba(X)[0]
|
||||||
|
best_idx = int(probas.argmax())
|
||||||
|
best_class = int(classifier.classes_[best_idx])
|
||||||
|
|
||||||
|
if best_class == -1:
|
||||||
|
return None
|
||||||
|
if threshold > 0.0 and probas[best_idx] < threshold:
|
||||||
|
return None
|
||||||
|
return best_class
|
||||||
|
|
||||||
|
|
||||||
ADVANCED_TEXT_PROCESSING_ENABLED = (
|
ADVANCED_TEXT_PROCESSING_ENABLED = (
|
||||||
settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED
|
settings.NLTK_LANGUAGE is not None and settings.NLTK_ENABLED
|
||||||
)
|
)
|
||||||
@@ -102,7 +123,8 @@ class DocumentClassifier:
|
|||||||
# v8 - Added storage path classifier
|
# v8 - Added storage path classifier
|
||||||
# v9 - Changed from hashing to time/ids for re-train check
|
# v9 - Changed from hashing to time/ids for re-train check
|
||||||
# v10 - HMAC-signed model file
|
# v10 - HMAC-signed model file
|
||||||
FORMAT_VERSION = 10
|
# v11 - Use sample_weight for balanced training; predict_proba with threshold
|
||||||
|
FORMAT_VERSION = 11
|
||||||
|
|
||||||
HMAC_SIZE = 32 # SHA-256 digest length
|
HMAC_SIZE = 32 # SHA-256 digest length
|
||||||
|
|
||||||
@@ -324,6 +346,13 @@ class DocumentClassifier:
|
|||||||
from sklearn.preprocessing import LabelBinarizer
|
from sklearn.preprocessing import LabelBinarizer
|
||||||
from sklearn.preprocessing import MultiLabelBinarizer
|
from sklearn.preprocessing import MultiLabelBinarizer
|
||||||
|
|
||||||
|
# MLPClassifier does not support class_weight directly
|
||||||
|
# (https://github.com/scikit-learn/scikit-learn/issues/9113), so we use
|
||||||
|
# compute_sample_weight to balance classes during training and prevent
|
||||||
|
# over-represented correspondents from dominating predictions.
|
||||||
|
# https://scikit-learn.org/stable/modules/generated/sklearn.utils.class_weight.compute_sample_weight.html
|
||||||
|
from sklearn.utils.class_weight import compute_sample_weight
|
||||||
|
|
||||||
# Step 2: vectorize data
|
# Step 2: vectorize data
|
||||||
logger.debug("Vectorizing data...")
|
logger.debug("Vectorizing data...")
|
||||||
notify("Vectorizing document content...")
|
notify("Vectorizing document content...")
|
||||||
@@ -369,7 +398,7 @@ class DocumentClassifier:
|
|||||||
self.tags_binarizer = MultiLabelBinarizer()
|
self.tags_binarizer = MultiLabelBinarizer()
|
||||||
labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags)
|
labels_tags_vectorized = self.tags_binarizer.fit_transform(labels_tags)
|
||||||
|
|
||||||
self.tags_classifier = MLPClassifier(tol=0.01)
|
self.tags_classifier = MLPClassifier(tol=0.01, random_state=0)
|
||||||
self.tags_classifier.fit(data_vectorized, labels_tags_vectorized)
|
self.tags_classifier.fit(data_vectorized, labels_tags_vectorized)
|
||||||
else:
|
else:
|
||||||
self.tags_classifier = None
|
self.tags_classifier = None
|
||||||
@@ -380,8 +409,12 @@ class DocumentClassifier:
|
|||||||
notify(
|
notify(
|
||||||
f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
|
f"Training correspondent classifier ({num_correspondents} correspondent(s))...",
|
||||||
)
|
)
|
||||||
self.correspondent_classifier = MLPClassifier(tol=0.01)
|
self.correspondent_classifier = MLPClassifier(tol=0.01, random_state=0)
|
||||||
self.correspondent_classifier.fit(data_vectorized, labels_correspondent)
|
self.correspondent_classifier.fit(
|
||||||
|
data_vectorized,
|
||||||
|
labels_correspondent,
|
||||||
|
sample_weight=compute_sample_weight("balanced", labels_correspondent),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.correspondent_classifier = None
|
self.correspondent_classifier = None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -393,8 +426,12 @@ class DocumentClassifier:
|
|||||||
notify(
|
notify(
|
||||||
f"Training document type classifier ({num_document_types} type(s))...",
|
f"Training document type classifier ({num_document_types} type(s))...",
|
||||||
)
|
)
|
||||||
self.document_type_classifier = MLPClassifier(tol=0.01)
|
self.document_type_classifier = MLPClassifier(tol=0.01, random_state=0)
|
||||||
self.document_type_classifier.fit(data_vectorized, labels_document_type)
|
self.document_type_classifier.fit(
|
||||||
|
data_vectorized,
|
||||||
|
labels_document_type,
|
||||||
|
sample_weight=compute_sample_weight("balanced", labels_document_type),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.document_type_classifier = None
|
self.document_type_classifier = None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -406,10 +443,11 @@ class DocumentClassifier:
|
|||||||
"Training storage paths classifier...",
|
"Training storage paths classifier...",
|
||||||
)
|
)
|
||||||
notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
|
notify(f"Training storage path classifier ({num_storage_paths} path(s))...")
|
||||||
self.storage_path_classifier = MLPClassifier(tol=0.01)
|
self.storage_path_classifier = MLPClassifier(tol=0.01, random_state=0)
|
||||||
self.storage_path_classifier.fit(
|
self.storage_path_classifier.fit(
|
||||||
data_vectorized,
|
data_vectorized,
|
||||||
labels_storage_path,
|
labels_storage_path,
|
||||||
|
sample_weight=compute_sample_weight("balanced", labels_storage_path),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.storage_path_classifier = None
|
self.storage_path_classifier = None
|
||||||
@@ -546,24 +584,24 @@ class DocumentClassifier:
|
|||||||
def predict_correspondent(self, content: str) -> int | None:
|
def predict_correspondent(self, content: str) -> int | None:
|
||||||
if self.correspondent_classifier:
|
if self.correspondent_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
correspondent_id = self.correspondent_classifier.predict(X)
|
predicted_id = _predict_with_threshold(
|
||||||
if correspondent_id != -1:
|
self.correspondent_classifier,
|
||||||
return correspondent_id
|
X,
|
||||||
else:
|
settings.CLASSIFIER_MATCH_THRESHOLD,
|
||||||
return None
|
)
|
||||||
else:
|
return predicted_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def predict_document_type(self, content: str) -> int | None:
|
def predict_document_type(self, content: str) -> int | None:
|
||||||
if self.document_type_classifier:
|
if self.document_type_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
document_type_id = self.document_type_classifier.predict(X)
|
predicted_id = _predict_with_threshold(
|
||||||
if document_type_id != -1:
|
self.document_type_classifier,
|
||||||
return document_type_id
|
X,
|
||||||
else:
|
settings.CLASSIFIER_MATCH_THRESHOLD,
|
||||||
return None
|
)
|
||||||
else:
|
return predicted_id
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def predict_tags(self, content: str) -> list[int]:
|
def predict_tags(self, content: str) -> list[int]:
|
||||||
from sklearn.utils.multiclass import type_of_target
|
from sklearn.utils.multiclass import type_of_target
|
||||||
@@ -589,10 +627,10 @@ class DocumentClassifier:
|
|||||||
def predict_storage_path(self, content: str) -> int | None:
|
def predict_storage_path(self, content: str) -> int | None:
|
||||||
if self.storage_path_classifier:
|
if self.storage_path_classifier:
|
||||||
X = self._vectorize(content)
|
X = self._vectorize(content)
|
||||||
storage_path_id = self.storage_path_classifier.predict(X)
|
predicted_id = _predict_with_threshold(
|
||||||
if storage_path_id != -1:
|
self.storage_path_classifier,
|
||||||
return storage_path_id
|
X,
|
||||||
else:
|
settings.CLASSIFIER_MATCH_THRESHOLD,
|
||||||
return None
|
)
|
||||||
else:
|
return predicted_id
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -156,6 +156,15 @@ class FileStabilityTracker:
|
|||||||
logger.debug(f"File disappeared during stability check: {path}")
|
logger.debug(f"File disappeared during stability check: {path}")
|
||||||
continue
|
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
|
# File is stable, we can return it
|
||||||
to_yield.append(path)
|
to_yield.append(path)
|
||||||
logger.info(f"File is stable: {path}")
|
logger.info(f"File is stable: {path}")
|
||||||
|
|||||||
+24
-2
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -374,6 +375,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
If the queryset already annotated ``effective_content``, that value is used.
|
If the queryset already annotated ``effective_content``, that value is used.
|
||||||
"""
|
"""
|
||||||
# Here to avoid circular import
|
# Here to avoid circular import
|
||||||
|
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
from documents.versioning import versions_newest_first
|
from documents.versioning import versions_newest_first
|
||||||
|
|
||||||
@@ -383,6 +385,19 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
if self.root_document_id is not None or self.pk is None:
|
if self.root_document_id is not None or self.pk is None:
|
||||||
return self.content
|
return self.content
|
||||||
|
|
||||||
|
latest_version_prefetch = getattr(
|
||||||
|
self,
|
||||||
|
LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if latest_version_prefetch is not None:
|
||||||
|
# Empty list means prefetch ran and found no versions — use own content.
|
||||||
|
return (
|
||||||
|
latest_version_prefetch[0].content
|
||||||
|
if latest_version_prefetch
|
||||||
|
else self.content
|
||||||
|
)
|
||||||
|
|
||||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
||||||
prefetched_versions = (
|
prefetched_versions = (
|
||||||
prefetched_cache.get("versions")
|
prefetched_cache.get("versions")
|
||||||
@@ -514,13 +529,20 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
def delete(
|
def delete(
|
||||||
self,
|
self,
|
||||||
*args,
|
*args,
|
||||||
|
transaction_id=None,
|
||||||
**kwargs,
|
**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:
|
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(
|
return super().delete(
|
||||||
*args,
|
*args,
|
||||||
|
transaction_id=transaction_id,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,179 @@ def set_permissions_for_object(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
||||||
|
"""
|
||||||
|
Resolves `codenames` to Permission rows, raising like the single-object
|
||||||
|
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
||||||
|
if any codename doesn't exist -- e.g. a client-supplied action name that
|
||||||
|
was never validated (BulkEditObjectsSerializer._validate_permissions
|
||||||
|
calls validate_set_permissions() only for its side-effecting id checks
|
||||||
|
and discards the filtered dict it returns, so an unrecognized action key
|
||||||
|
reaches this function as-is). A plain `.filter()` with no existence
|
||||||
|
check would otherwise silently build zero rows and no-op instead of
|
||||||
|
reporting the bad input.
|
||||||
|
"""
|
||||||
|
permission_objs = list(
|
||||||
|
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
||||||
|
)
|
||||||
|
missing = codenames - {p.codename for p in permission_objs}
|
||||||
|
if missing:
|
||||||
|
raise Permission.DoesNotExist(
|
||||||
|
f"Permission matching query does not exist for codename(s): "
|
||||||
|
f"{', '.join(sorted(missing))}",
|
||||||
|
)
|
||||||
|
return permission_objs
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_bulk_permission_entry(
|
||||||
|
*,
|
||||||
|
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
||||||
|
identity_model: type[User] | type[Group],
|
||||||
|
identity_field: str,
|
||||||
|
ids: list[int],
|
||||||
|
codename: str,
|
||||||
|
permission_objs: list[Permission],
|
||||||
|
ctype: ContentType,
|
||||||
|
object_pks: list[str],
|
||||||
|
merge: bool,
|
||||||
|
) -> None:
|
||||||
|
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
||||||
|
# so avoid fetching full User/Group rows for identities that may not
|
||||||
|
# even end up being granted anything new.
|
||||||
|
add_ids = set(
|
||||||
|
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not merge:
|
||||||
|
existing_ids = set(
|
||||||
|
perm_model.objects.filter(
|
||||||
|
content_type=ctype,
|
||||||
|
object_pk__in=object_pks,
|
||||||
|
permission__codename=codename,
|
||||||
|
)
|
||||||
|
.values_list(f"{identity_field}_id", flat=True)
|
||||||
|
.distinct(),
|
||||||
|
)
|
||||||
|
remove_ids = existing_ids - add_ids
|
||||||
|
if remove_ids:
|
||||||
|
perm_model.objects.filter(
|
||||||
|
content_type=ctype,
|
||||||
|
object_pk__in=object_pks,
|
||||||
|
permission__codename=codename,
|
||||||
|
**{f"{identity_field}_id__in": remove_ids},
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
if not add_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
perm_model(
|
||||||
|
content_type=ctype,
|
||||||
|
object_pk=pk,
|
||||||
|
permission=permission_obj,
|
||||||
|
**{f"{identity_field}_id": identity_id},
|
||||||
|
)
|
||||||
|
for permission_obj in permission_objs
|
||||||
|
for pk in object_pks
|
||||||
|
for identity_id in add_ids
|
||||||
|
]
|
||||||
|
# ignore_conflicts skips only rows that already exist as an exact
|
||||||
|
# (identity, permission, object) match -- the same de-dup the
|
||||||
|
# underlying (user|group, permission, object_pk) unique constraint
|
||||||
|
# already enforces for the single-object assign_perm() this replaces,
|
||||||
|
# so it doesn't change what counts as "already granted". batch_size
|
||||||
|
# caps how many rows go into a single INSERT statement.
|
||||||
|
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
||||||
|
|
||||||
|
|
||||||
|
def set_permissions_for_objects(
|
||||||
|
permissions: dict,
|
||||||
|
model: type[Model],
|
||||||
|
pks: QuerySet | list,
|
||||||
|
*,
|
||||||
|
merge: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Bulk equivalent of set_permissions_for_object: applies the same
|
||||||
|
permission changes to every object identified by `pks` at once.
|
||||||
|
|
||||||
|
Takes a model + pks (rather than model instances) deliberately -- the
|
||||||
|
permission rows built below only ever need `pk`, `content_type`, and
|
||||||
|
identity ids, so callers shouldn't have to fetch full rows (with every
|
||||||
|
other field) just to hand them to this function.
|
||||||
|
|
||||||
|
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
||||||
|
passing a list as the object routes to bulk_assign_perm, which skips
|
||||||
|
creating a direct permission row for anyone who already has the
|
||||||
|
permission via ANY group membership (it checks
|
||||||
|
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
||||||
|
unlike the single-object assign_perm this replaces, which always
|
||||||
|
ensures a direct row via get_or_create regardless of group-derived
|
||||||
|
access. Losing that guarantee would mean a later revocation of the
|
||||||
|
group's grant silently strips access an admin explicitly asked to be
|
||||||
|
direct. Bulk-creating rows straight against the permission models
|
||||||
|
instead (see _apply_bulk_permission_entry) preserves the original
|
||||||
|
always-create-a-direct-row semantics while still batching every object
|
||||||
|
and every identity into one query per action, rather than one query per
|
||||||
|
(object, user) pair.
|
||||||
|
"""
|
||||||
|
object_pks = [str(pk) for pk in pks]
|
||||||
|
if not object_pks: # pragma: no cover
|
||||||
|
return
|
||||||
|
|
||||||
|
model_name = model.__name__.lower()
|
||||||
|
ctype = ContentType.objects.get_for_model(model)
|
||||||
|
|
||||||
|
# Every action is resolved up front, before anything is written, so an
|
||||||
|
# unrecognized action name (see _resolve_permissions) aborts the whole
|
||||||
|
# call instead of leaving the actions ahead of it already applied --
|
||||||
|
# BulkEditObjectsSerializer lets unknown keys through and its view turns
|
||||||
|
# the exception into a 400, so a half-applied change would otherwise be
|
||||||
|
# reported to the client as a failure.
|
||||||
|
permissions_by_action: dict[str, list[Permission]] = {}
|
||||||
|
for action, entry in permissions.items():
|
||||||
|
if "users" not in entry and "groups" not in entry:
|
||||||
|
continue
|
||||||
|
implied_codenames = {f"{action}_{model_name}"}
|
||||||
|
if action == "change":
|
||||||
|
# change gives view too
|
||||||
|
implied_codenames.add(f"view_{model_name}")
|
||||||
|
permissions_by_action[action] = _resolve_permissions(
|
||||||
|
implied_codenames,
|
||||||
|
ctype,
|
||||||
|
)
|
||||||
|
|
||||||
|
for action, entry in permissions.items():
|
||||||
|
codename = f"{action}_{model_name}"
|
||||||
|
permission_objs = permissions_by_action.get(action, [])
|
||||||
|
|
||||||
|
if "users" in entry:
|
||||||
|
_apply_bulk_permission_entry(
|
||||||
|
perm_model=UserObjectPermission,
|
||||||
|
identity_model=User,
|
||||||
|
identity_field="user",
|
||||||
|
ids=entry["users"],
|
||||||
|
codename=codename,
|
||||||
|
permission_objs=permission_objs,
|
||||||
|
ctype=ctype,
|
||||||
|
object_pks=object_pks,
|
||||||
|
merge=merge,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "groups" in entry:
|
||||||
|
_apply_bulk_permission_entry(
|
||||||
|
perm_model=GroupObjectPermission,
|
||||||
|
identity_model=Group,
|
||||||
|
identity_field="group",
|
||||||
|
ids=entry["groups"],
|
||||||
|
codename=codename,
|
||||||
|
permission_objs=permission_objs,
|
||||||
|
ctype=ctype,
|
||||||
|
object_pks=object_pks,
|
||||||
|
merge=merge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def permitted_object_ids(
|
def permitted_object_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
model: type[Model],
|
model: type[Model],
|
||||||
@@ -484,16 +657,41 @@ class ViewDocumentsPermissions(BasePermission):
|
|||||||
return request.user.has_perms(self.perms_map.get(request.method, []))
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
|
class TrashPermissions(BasePermission):
|
||||||
|
"""Check the global document permission for each trash operation."""
|
||||||
|
|
||||||
|
perms_map = {
|
||||||
|
"OPTIONS": ["documents.view_document"],
|
||||||
|
"HEAD": ["documents.view_document"],
|
||||||
|
"GET": ["documents.view_document"],
|
||||||
|
"POST": ["documents.delete_document"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated: # pragma: no cover
|
||||||
|
return False
|
||||||
|
|
||||||
|
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||||
|
|
||||||
|
|
||||||
class PaperlessNotePermissions(BasePermission):
|
class PaperlessNotePermissions(BasePermission):
|
||||||
"""
|
"""
|
||||||
Permissions class that checks for model permissions for Notes.
|
Permissions class that checks for model permissions for Notes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
"OPTIONS": ["documents.view_note"],
|
"OPTIONS": ["documents.view_note", "documents.view_document"],
|
||||||
"GET": ["documents.view_note"],
|
"GET": ["documents.view_note", "documents.view_document"],
|
||||||
"POST": ["documents.add_note"],
|
"POST": [
|
||||||
"DELETE": ["documents.delete_note"],
|
"documents.add_note",
|
||||||
|
"documents.view_document",
|
||||||
|
"documents.change_document",
|
||||||
|
],
|
||||||
|
"DELETE": [
|
||||||
|
"documents.delete_note",
|
||||||
|
"documents.view_document",
|
||||||
|
"documents.change_document",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ from documents.templating.utils import convert_format_str_to_template_format
|
|||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
|
from documents.versioning import has_prefetched_effective_content
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -1152,8 +1153,14 @@ class DocumentSerializer(
|
|||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
doc = super().to_representation(instance)
|
doc = super().to_representation(instance)
|
||||||
if "content" in self.fields and hasattr(instance, "effective_content"):
|
if "content" in self.fields and has_prefetched_effective_content(instance):
|
||||||
doc["content"] = getattr(instance, "effective_content") or ""
|
# Only resolve version-aware content when it's cheap: an SQL
|
||||||
|
# annotation or a versions prefetch is already on the instance.
|
||||||
|
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
|
||||||
|
# which build their own querysets) gets the document's own,
|
||||||
|
# unresolved content instead of paying for an extra per-instance
|
||||||
|
# query -- same as before effective_content resolution existed.
|
||||||
|
doc["content"] = instance.get_effective_content() or ""
|
||||||
if self.truncate_content and "content" in self.fields:
|
if self.truncate_content and "content" in self.fields:
|
||||||
doc["content"] = doc.get("content")[0:550]
|
doc["content"] = doc.get("content")[0:550]
|
||||||
return doc
|
return doc
|
||||||
@@ -1247,30 +1254,31 @@ class DocumentSerializer(
|
|||||||
|
|
||||||
validated_data["tags"] = list(final_tags)
|
validated_data["tags"] = list(final_tags)
|
||||||
if validated_data.get("remove_inbox_tags"):
|
if validated_data.get("remove_inbox_tags"):
|
||||||
tag_ids_being_added = (
|
current_tag_ids = {t.pk for t in instance.tags.all()}
|
||||||
[
|
tags = (
|
||||||
tag.id
|
validated_data["tags"]
|
||||||
for tag in validated_data["tags"]
|
|
||||||
if tag not in instance.tags.all()
|
|
||||||
]
|
|
||||||
if "tags" in validated_data
|
if "tags" in validated_data
|
||||||
else []
|
else list(instance.tags.all())
|
||||||
)
|
)
|
||||||
inbox_tags_not_being_added = Tag.objects.filter(is_inbox_tag=True).exclude(
|
|
||||||
id__in=tag_ids_being_added,
|
# Tags newly added in this update, plus their ancestors, are kept
|
||||||
)
|
keep_ids: set[int] = set()
|
||||||
if "tags" in validated_data:
|
for tag in tags:
|
||||||
validated_data["tags"] = [
|
if tag.pk not in current_tag_ids:
|
||||||
tag
|
keep_ids.add(tag.pk)
|
||||||
for tag in validated_data["tags"]
|
keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
|
||||||
if tag not in inbox_tags_not_being_added
|
|
||||||
]
|
# Remove inbox tags and their descendants, except those being kept
|
||||||
else:
|
remove_ids: set[int] = set()
|
||||||
validated_data["tags"] = [
|
for inbox_tag in (
|
||||||
tag
|
Tag.objects.filter(is_inbox_tag=True)
|
||||||
for tag in instance.tags.all()
|
.exclude(pk__in=keep_ids)
|
||||||
if tag not in inbox_tags_not_being_added
|
.only("pk", "tn_descendants_pks")
|
||||||
]
|
):
|
||||||
|
remove_ids.add(inbox_tag.pk)
|
||||||
|
remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks())
|
||||||
|
|
||||||
|
validated_data["tags"] = [t for t in tags if t.pk not in remove_ids]
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
with set_actor(self.user):
|
with set_actor(self.user):
|
||||||
@@ -1327,6 +1335,7 @@ class DocumentSerializer(
|
|||||||
"root_document",
|
"root_document",
|
||||||
"versions",
|
"versions",
|
||||||
)
|
)
|
||||||
|
read_only_fields = ("deleted_at",)
|
||||||
list_serializer_class = OwnedObjectListSerializer
|
list_serializer_class = OwnedObjectListSerializer
|
||||||
|
|
||||||
|
|
||||||
@@ -1741,7 +1750,7 @@ class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
|
||||||
operations = serializers.ListField(required=True)
|
operations = serializers.ListField(required=True, allow_empty=False)
|
||||||
delete_original = serializers.BooleanField(required=False, default=False)
|
delete_original = serializers.BooleanField(required=False, default=False)
|
||||||
update_document = serializers.BooleanField(required=False, default=False)
|
update_document = serializers.BooleanField(required=False, default=False)
|
||||||
include_metadata = serializers.BooleanField(required=False, default=True)
|
include_metadata = serializers.BooleanField(required=False, default=True)
|
||||||
@@ -1779,6 +1788,12 @@ class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMix
|
|||||||
"update_document only allowed with a single output document",
|
"update_document only allowed with a single output document",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(operations)
|
||||||
|
for op in operations
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError("doc index is out of bounds")
|
||||||
|
|
||||||
doc = Document.objects.get(id=documents[0])
|
doc = Document.objects.get(id=documents[0])
|
||||||
if doc.page_count:
|
if doc.page_count:
|
||||||
for op in operations:
|
for op in operations:
|
||||||
@@ -2115,6 +2130,8 @@ class BulkEditSerializer(
|
|||||||
raise serializers.ValidationError("operations not specified")
|
raise serializers.ValidationError("operations not specified")
|
||||||
if not isinstance(parameters["operations"], list):
|
if not isinstance(parameters["operations"], list):
|
||||||
raise serializers.ValidationError("operations must be a list")
|
raise serializers.ValidationError("operations must be a list")
|
||||||
|
if not parameters["operations"]:
|
||||||
|
raise serializers.ValidationError("operations must not be empty")
|
||||||
for op in parameters["operations"]:
|
for op in parameters["operations"]:
|
||||||
if not isinstance(op, dict):
|
if not isinstance(op, dict):
|
||||||
raise serializers.ValidationError("invalid operation entry")
|
raise serializers.ValidationError("invalid operation entry")
|
||||||
@@ -2142,6 +2159,12 @@ class BulkEditSerializer(
|
|||||||
"update_document only allowed with a single output document",
|
"update_document only allowed with a single output document",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
op.get("doc", 0) < 0 or op.get("doc", 0) >= len(parameters["operations"])
|
||||||
|
for op in parameters["operations"]
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError("doc index is out of bounds")
|
||||||
|
|
||||||
doc = Document.objects.get(id=document_id)
|
doc = Document.objects.get(id=document_id)
|
||||||
# doc existence is already validated
|
# doc existence is already validated
|
||||||
if doc.page_count:
|
if doc.page_count:
|
||||||
@@ -2831,10 +2854,14 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
|||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
def validate_document(self, document):
|
def validate_document(self, document):
|
||||||
if self.user is not None and has_perms_owner_aware(
|
if (
|
||||||
self.user,
|
self.user is not None
|
||||||
"view_document",
|
and self.user.has_perm("documents.view_document")
|
||||||
document,
|
and has_perms_owner_aware(
|
||||||
|
self.user,
|
||||||
|
"view_document",
|
||||||
|
document,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return document
|
return document
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
@@ -3595,6 +3622,8 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
|
|
||||||
if "actions" in validated_data:
|
if "actions" in validated_data:
|
||||||
actions = validated_data.pop("actions")
|
actions = validated_data.pop("actions")
|
||||||
|
for action in actions:
|
||||||
|
action.pop("id", None)
|
||||||
|
|
||||||
instance = super().create(validated_data)
|
instance = super().create(validated_data)
|
||||||
|
|
||||||
|
|||||||
@@ -1189,13 +1189,18 @@ def before_task_publish_handler(
|
|||||||
trigger_source = _determine_trigger_source(headers)
|
trigger_source = _determine_trigger_source(headers)
|
||||||
owner_id = _extract_owner_id(task_type, task_kwargs)
|
owner_id = _extract_owner_id(task_type, task_kwargs)
|
||||||
|
|
||||||
PaperlessTask.objects.create(
|
# A retried task is republished with the same task_id, so this fires
|
||||||
|
# again for it; get_or_create keeps the original PENDING record
|
||||||
|
# instead of raising a duplicate-key IntegrityError on the retry.
|
||||||
|
PaperlessTask.objects.get_or_create(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
task_type=task_type,
|
defaults={
|
||||||
trigger_source=trigger_source,
|
"task_type": task_type,
|
||||||
status=PaperlessTask.Status.PENDING,
|
"trigger_source": trigger_source,
|
||||||
input_data=input_data,
|
"status": PaperlessTask.Status.PENDING,
|
||||||
owner_id=owner_id,
|
"input_data": input_data,
|
||||||
|
"owner_id": owner_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
logger.exception("Creating PaperlessTask failed")
|
logger.exception("Creating PaperlessTask failed")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -326,6 +327,9 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|||||||
|
|
||||||
def test_download_insufficient_permissions(self) -> None:
|
def test_download_insufficient_permissions(self) -> None:
|
||||||
user = User.objects.create_user(username="temp_user")
|
user = User.objects.create_user(username="temp_user")
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.client.force_authenticate(user=user)
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
self.doc2.owner = self.user
|
self.doc2.owner = self.user
|
||||||
|
|||||||
@@ -1084,6 +1084,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
|
assign_perm("view_document", user1, self.doc2)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/selection_data/",
|
"/api/documents/selection_data/",
|
||||||
json.dumps({"documents": [self.doc2.id]}),
|
json.dumps({"documents": [self.doc2.id]}),
|
||||||
@@ -1091,7 +1093,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
self.assertEqual(response.content, b"Insufficient permissions")
|
|
||||||
|
user1.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user1 = User.objects.get(pk=user1.pk)
|
||||||
|
self.client.force_authenticate(user=user1)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/selection_data/",
|
||||||
|
json.dumps({"documents": [self.doc2.id]}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions(self, m) -> None:
|
def test_set_permissions(self, m) -> None:
|
||||||
@@ -1636,6 +1649,40 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_legacy_bulk_edit_rejects_out_of_bounds_pdf_doc_index(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "edit_pdf",
|
||||||
|
"parameters": {
|
||||||
|
"operations": [{"page": 1, "doc": 2**32}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"doc index is out of bounds", response.content)
|
||||||
|
|
||||||
|
def test_legacy_bulk_edit_rejects_empty_pdf_operations(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/bulk_edit/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"method": "edit_pdf",
|
||||||
|
"parameters": {"operations": []},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"operations must not be empty", response.content)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf(self, m) -> None:
|
def test_edit_pdf(self, m) -> None:
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
@@ -1686,6 +1733,13 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"Expected a list of items", response.content)
|
self.assertIn(b"Expected a list of items", response.content)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/edit_pdf/",
|
||||||
|
{"documents": [self.doc2.id], "operations": []},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/edit_pdf/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@@ -1738,6 +1792,21 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"doc must be an integer", response.content)
|
self.assertIn(b"doc must be an integer", response.content)
|
||||||
|
|
||||||
|
for doc_index in (-1, 2**32):
|
||||||
|
with self.subTest(doc_index=doc_index):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/documents/edit_pdf/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"documents": [self.doc2.id],
|
||||||
|
"operations": [{"page": 1, "doc": doc_index}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn(b"doc index is out of bounds", response.content)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/edit_pdf/",
|
"/api/documents/edit_pdf/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|||||||
@@ -38,6 +38,42 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
def test_answer_is_not_compressed(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A client that accepts compressed responses
|
||||||
|
WHEN:
|
||||||
|
- It asks the chat endpoint a question
|
||||||
|
THEN:
|
||||||
|
- The answer is streamed unencoded, chunk for chunk
|
||||||
|
|
||||||
|
The stream compressors buffer, so a compressed answer arrives in one
|
||||||
|
piece. The view cannot opt out by flagging the request: DRF's request
|
||||||
|
wrapper proxies reads but keeps writes to itself, so the flag never
|
||||||
|
reaches the Django request the middleware sees.
|
||||||
|
"""
|
||||||
|
chunks = [f"token{i} " for i in range(40)]
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.views.AIConfig",
|
||||||
|
return_value=self._mock_ai_enabled(),
|
||||||
|
),
|
||||||
|
mock.patch(
|
||||||
|
"documents.views.stream_chat_with_documents",
|
||||||
|
return_value=iter(chunks),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/documents/chat/",
|
||||||
|
{"q": "What is in my archive?"},
|
||||||
|
format="json",
|
||||||
|
HTTP_ACCEPT_ENCODING="gzip, deflate, br, zstd",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == status.HTTP_200_OK
|
||||||
|
assert not resp.has_header("Content-Encoding")
|
||||||
|
assert list(resp.streaming_content) == [c.encode() for c in chunks]
|
||||||
|
|
||||||
def test_missing_question_is_rejected(self) -> None:
|
def test_missing_question_is_rejected(self) -> None:
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"documents.views.AIConfig",
|
"documents.views.AIConfig",
|
||||||
|
|||||||
@@ -3615,6 +3615,55 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
self.assertEqual(response.content, b"Insufficient permissions to delete notes")
|
self.assertEqual(response.content, b"Insufficient permissions to delete notes")
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
def test_notes_require_global_document_permissions(self) -> None:
|
||||||
|
user = User.objects.create_user(username="note_editor")
|
||||||
|
user.user_permissions.add(
|
||||||
|
*Permission.objects.filter(
|
||||||
|
codename__in=["view_note", "add_note", "delete_note"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="test",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="notes",
|
||||||
|
owner=user,
|
||||||
|
)
|
||||||
|
note = Note.objects.create(note="Existing", document=doc, user=user)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user = User.objects.get(pk=user.pk)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
response = self.client.get(f"/api/documents/{doc.pk}/notes/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/documents/{doc.pk}/notes/",
|
||||||
|
data={"note": "New"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="change_document"),
|
||||||
|
)
|
||||||
|
user = User.objects.get(pk=user.pk)
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/documents/{doc.pk}/notes/",
|
||||||
|
data={"note": "New"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
response = self.client.delete(
|
||||||
|
f"/api/documents/{doc.pk}/notes/?id={note.pk}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_delete_note(self) -> None:
|
def test_delete_note(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -3981,6 +4030,21 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
|
|
||||||
assign_perm("view_document", user1, doc)
|
assign_perm("view_document", user1, doc)
|
||||||
|
|
||||||
|
create_resp = self.client.post(
|
||||||
|
"/api/share_links/",
|
||||||
|
data={
|
||||||
|
"document": doc.pk,
|
||||||
|
"file_version": "original",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(create_resp.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
user1.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
user1 = User.objects.get(pk=user1.pk)
|
||||||
|
self.client.force_authenticate(user1)
|
||||||
create_resp = self.client.post(
|
create_resp = self.client.post(
|
||||||
"/api/share_links/",
|
"/api/share_links/",
|
||||||
data={
|
data={
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import connection
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@@ -452,6 +457,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
def test_test_storage_path_requires_document_view_permission(self) -> None:
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
unprivileged = User.objects.create_user(username="unprivileged")
|
unprivileged = User.objects.create_user(username="unprivileged")
|
||||||
|
unprivileged.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -483,6 +491,23 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=viewer)
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}test/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"document": document.id,
|
||||||
|
"path": "path/{{ title }}",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
viewer = User.objects.get(pk=viewer.pk)
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f"{self.ENDPOINT}test/",
|
f"{self.ENDPOINT}test/",
|
||||||
@@ -525,6 +550,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
password="password",
|
password="password",
|
||||||
email="owner@example.com",
|
email="owner@example.com",
|
||||||
)
|
)
|
||||||
|
owner.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -600,6 +628,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
checksum="123",
|
checksum="123",
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -687,6 +718,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
document.tags.add(private_tag)
|
document.tags.add(private_tag)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -740,6 +774,9 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
|||||||
value_int=42,
|
value_int=42,
|
||||||
)
|
)
|
||||||
assign_perm("view_document", viewer, document)
|
assign_perm("view_document", viewer, document)
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_authenticate(user=viewer)
|
self.client.force_authenticate(user=viewer)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
@@ -842,6 +879,66 @@ class TestBulkEditObjects(APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(StoragePath.objects.count(), 0)
|
self.assertEqual(StoragePath.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_bulk_objects_set_permissions_batched_across_object_count(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Many tags are being bulk-edited to set permissions at once
|
||||||
|
WHEN:
|
||||||
|
- bulk_edit_objects API endpoint is called with set_permissions
|
||||||
|
operation over a small batch vs. a much larger one
|
||||||
|
THEN:
|
||||||
|
- Permissions are applied correctly at both scales
|
||||||
|
- Query count does not grow with the number of tags, i.e. each
|
||||||
|
user/group is applied across all tags with one batched call
|
||||||
|
rather than one call per (tag, identity) pair
|
||||||
|
"""
|
||||||
|
group1 = Group.objects.create(name="perm-group")
|
||||||
|
permissions = {
|
||||||
|
"view": {"users": [self.user1.id, self.user2.id], "groups": [group1.id]},
|
||||||
|
"change": {"users": [self.user1.id], "groups": [group1.id]},
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_with_n_tags(n: int) -> int:
|
||||||
|
tags = [Tag.objects.create(name=f"perm-tag-{n}-{i}") for i in range(n)]
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/bulk_edit_objects/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"objects": [t.id for t in tags],
|
||||||
|
"object_type": "tags",
|
||||||
|
"operation": "set_permissions",
|
||||||
|
"permissions": permissions,
|
||||||
|
"merge": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
for tag in tags:
|
||||||
|
self.assertEqual(get_users_with_perms(tag).count(), 2)
|
||||||
|
self.assertEqual(get_groups_with_perms(tag).count(), 1)
|
||||||
|
return len(ctx.captured_queries)
|
||||||
|
|
||||||
|
small_batch_queries = run_with_n_tags(5)
|
||||||
|
large_batch_queries = run_with_n_tags(50)
|
||||||
|
|
||||||
|
# A tolerance rather than equality, matching the N+1 check in
|
||||||
|
# test_views.py: bulk_create's batch_size caps rows per INSERT, so a
|
||||||
|
# large enough selection does legitimately add statements, and the
|
||||||
|
# per-process ContentType cache makes the first run carry an extra
|
||||||
|
# query. Neither can hide a regression to per-object assignment,
|
||||||
|
# which would be ~10x the small-batch count here.
|
||||||
|
self.assertLessEqual(
|
||||||
|
large_batch_queries,
|
||||||
|
small_batch_queries + 5,
|
||||||
|
"Permission assignment appears to scale with object count: "
|
||||||
|
f"{small_batch_queries} queries for 5 tags vs. "
|
||||||
|
f"{large_batch_queries} for 50",
|
||||||
|
)
|
||||||
|
|
||||||
def test_bulk_objects_delete_all_filtered(self) -> None:
|
def test_bulk_objects_delete_all_filtered(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(Document.global_objects.count(), 0)
|
self.assertEqual(Document.global_objects.count(), 0)
|
||||||
|
|
||||||
|
def test_trash_list_requires_global_document_view_permission(self) -> None:
|
||||||
|
user = User.objects.create_user(username="trash_owner")
|
||||||
|
document = Document.objects.create(title="Owned", owner=user)
|
||||||
|
document.delete()
|
||||||
|
self.client.force_authenticate(user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/trash/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_trash_api_empty_all(self) -> None:
|
def test_trash_api_empty_all(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -207,3 +217,65 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
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],
|
||||||
|
)
|
||||||
|
|||||||
@@ -194,6 +194,48 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Workflow.objects.count(), 2)
|
self.assertEqual(Workflow.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_api_create_workflow_ignores_nested_action_id(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing workflow action
|
||||||
|
WHEN:
|
||||||
|
- API request to create a workflow includes that action's ID
|
||||||
|
THEN:
|
||||||
|
- A new action is created without changing the existing action
|
||||||
|
"""
|
||||||
|
original_title = self.action.assign_title
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Workflow 2",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"sources": [DocumentSource.ApiUpload],
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"filter_filename": "*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": self.action.id,
|
||||||
|
"assign_title": "New Action Title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.action.refresh_from_db()
|
||||||
|
self.assertEqual(self.action.assign_title, original_title)
|
||||||
|
new_action = Workflow.objects.get(name="Workflow 2").actions.get()
|
||||||
|
self.assertNotEqual(new_action.id, self.action.id)
|
||||||
|
self.assertEqual(new_action.assign_title, "New Action Title")
|
||||||
|
|
||||||
def test_api_create_workflow_nested(self) -> None:
|
def test_api_create_workflow_nested(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ from unittest import mock
|
|||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import connection
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
@@ -19,6 +22,7 @@ from documents.models import Document
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.permissions import set_permissions_for_objects
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -392,6 +396,11 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
||||||
self.assertFalse(Document.objects.filter(id=version.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:
|
def test_delete_version_document_keeps_root(self) -> None:
|
||||||
version = Document.objects.create(
|
version = Document.objects.create(
|
||||||
checksum="A-v1",
|
checksum="A-v1",
|
||||||
@@ -510,6 +519,178 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(groups_with_perms.count(), 2)
|
self.assertEqual(groups_with_perms.count(), 2)
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
||||||
|
def test_set_permissions_batched_across_document_count(
|
||||||
|
self,
|
||||||
|
m,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Many documents are being bulk-edited to set permissions at once
|
||||||
|
WHEN:
|
||||||
|
- set_permissions runs over a small batch vs. a much larger one
|
||||||
|
THEN:
|
||||||
|
- Permissions are applied correctly at both scales
|
||||||
|
- Query count does not grow with the number of documents, i.e.
|
||||||
|
each user/group is applied across all documents with one
|
||||||
|
batched call rather than one call per (document, identity)
|
||||||
|
pair
|
||||||
|
"""
|
||||||
|
permissions = {
|
||||||
|
"view": {
|
||||||
|
"users": [self.user1.id, self.user2.id],
|
||||||
|
"groups": [self.group2.id],
|
||||||
|
},
|
||||||
|
"change": {
|
||||||
|
"users": [self.user1.id],
|
||||||
|
"groups": [self.group2.id],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_with_n_documents(n: int) -> int:
|
||||||
|
docs = [
|
||||||
|
Document.objects.create(checksum=f"perm-{n}-{i}", title=f"perm-{n}-{i}")
|
||||||
|
for i in range(n)
|
||||||
|
]
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
bulk_edit.set_permissions(
|
||||||
|
[doc.id for doc in docs],
|
||||||
|
set_permissions=permissions,
|
||||||
|
owner=self.owner,
|
||||||
|
merge=False,
|
||||||
|
)
|
||||||
|
for doc in docs:
|
||||||
|
self.assertEqual(get_users_with_perms(doc).count(), 2)
|
||||||
|
self.assertEqual(get_groups_with_perms(doc).count(), 1)
|
||||||
|
return len(ctx.captured_queries)
|
||||||
|
|
||||||
|
small_batch_queries = run_with_n_documents(5)
|
||||||
|
large_batch_queries = run_with_n_documents(50)
|
||||||
|
|
||||||
|
# A tolerance rather than equality, matching the N+1 check in
|
||||||
|
# test_views.py: bulk_create's batch_size caps rows per INSERT, so a
|
||||||
|
# large enough selection does legitimately add statements, and the
|
||||||
|
# per-process ContentType cache makes the first run carry an extra
|
||||||
|
# query. Neither can hide a regression to per-document assignment,
|
||||||
|
# which would be ~10x the small-batch count here.
|
||||||
|
self.assertLessEqual(
|
||||||
|
large_batch_queries,
|
||||||
|
small_batch_queries + 5,
|
||||||
|
"Permission assignment appears to scale with document count: "
|
||||||
|
f"{small_batch_queries} queries for 5 documents vs. "
|
||||||
|
f"{large_batch_queries} for 50",
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
||||||
|
def test_set_permissions_grants_direct_perm_even_if_already_granted_via_group(
|
||||||
|
self,
|
||||||
|
m,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A user already has view access to a document via group
|
||||||
|
membership, with no direct grant of their own
|
||||||
|
WHEN:
|
||||||
|
- set_permissions explicitly grants that same user direct view
|
||||||
|
access via bulk_edit
|
||||||
|
THEN:
|
||||||
|
- A direct permission grant is created for the user, not skipped
|
||||||
|
because they already have equivalent access via the group
|
||||||
|
|
||||||
|
Regression test: guardian's queryset-aware assign_perm() (routed to
|
||||||
|
when the target is a list/queryset) skips creating a direct row for
|
||||||
|
anyone whose ObjectPermissionChecker.has_perm() already returns True
|
||||||
|
-- which includes group-derived access. The single-object assign_perm
|
||||||
|
this bulk path replaces has no such check; it always ensures a
|
||||||
|
direct row via get_or_create. Losing that guarantee would mean
|
||||||
|
revoking the group's grant later silently strips access that was
|
||||||
|
supposed to be explicit.
|
||||||
|
"""
|
||||||
|
self.doc1.owner = self.user1
|
||||||
|
self.doc1.save()
|
||||||
|
self.user1.groups.add(self.group1)
|
||||||
|
assign_perm("view_document", self.group1, self.doc1)
|
||||||
|
|
||||||
|
bulk_edit.set_permissions(
|
||||||
|
[self.doc1.id],
|
||||||
|
set_permissions={
|
||||||
|
"view": {"users": [self.user1.id], "groups": []},
|
||||||
|
},
|
||||||
|
merge=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
direct_users = get_users_with_perms(
|
||||||
|
self.doc1,
|
||||||
|
only_with_perms_in=["view_document"],
|
||||||
|
with_group_users=False,
|
||||||
|
)
|
||||||
|
self.assertIn(self.user1, direct_users)
|
||||||
|
|
||||||
|
def test_set_permissions_for_objects_raises_for_unknown_action(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An unrecognized permission action name with users to grant it
|
||||||
|
to
|
||||||
|
WHEN:
|
||||||
|
- set_permissions_for_objects is called
|
||||||
|
THEN:
|
||||||
|
- Permission.DoesNotExist is raised, not a silent no-op
|
||||||
|
|
||||||
|
Regression test: the endpoint that calls this
|
||||||
|
(BulkEditObjectPermissionsView) never actually validates action
|
||||||
|
names against the raw client-supplied permissions dict --
|
||||||
|
BulkEditObjectsSerializer._validate_permissions calls
|
||||||
|
validate_set_permissions() only for its side-effecting user/group id
|
||||||
|
checks and discards the filtered dict it returns -- so a bogus
|
||||||
|
action key reaches this function as-is. Resolving the Permission via
|
||||||
|
a bare `.filter()` (which returns empty instead of raising) would
|
||||||
|
silently drop the grant and report success.
|
||||||
|
"""
|
||||||
|
with self.assertRaises(Permission.DoesNotExist):
|
||||||
|
set_permissions_for_objects(
|
||||||
|
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
||||||
|
Document,
|
||||||
|
[self.doc1.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_set_permissions_for_objects_unknown_action_applies_nothing(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A permissions dict with a valid action ordered ahead of an
|
||||||
|
unrecognized one
|
||||||
|
WHEN:
|
||||||
|
- set_permissions_for_objects is called
|
||||||
|
THEN:
|
||||||
|
- Permission.DoesNotExist is raised
|
||||||
|
- The valid action ahead of it is not applied either
|
||||||
|
|
||||||
|
Every action is resolved before any row is written, so a bad action
|
||||||
|
name cannot leave a half-applied change behind. That matters because
|
||||||
|
BulkEditObjectsView turns this exception into a 400: without the
|
||||||
|
up-front resolution the client would be told the request failed
|
||||||
|
while the leading action had already been committed.
|
||||||
|
"""
|
||||||
|
with self.assertRaises(Permission.DoesNotExist):
|
||||||
|
set_permissions_for_objects(
|
||||||
|
{
|
||||||
|
"view": {"users": [self.user1.id], "groups": []},
|
||||||
|
"not_a_real_action": {"users": [self.user1.id], "groups": []},
|
||||||
|
},
|
||||||
|
Document,
|
||||||
|
[self.doc1.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn(
|
||||||
|
self.user1,
|
||||||
|
get_users_with_perms(
|
||||||
|
self.doc1,
|
||||||
|
only_with_perms_in=["view_document"],
|
||||||
|
with_group_users=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@mock.patch("documents.models.Document.delete")
|
@mock.patch("documents.models.Document.delete")
|
||||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||||
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
||||||
@@ -1461,6 +1642,16 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
mock_group.assert_not_called()
|
mock_group.assert_not_called()
|
||||||
mock_consume_file.assert_not_called()
|
mock_consume_file.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("pikepdf.open")
|
||||||
|
def test_edit_pdf_rejects_invalid_operations(self, mock_open) -> None:
|
||||||
|
for operations in ([], [{"page": 1, "doc": 2**32}]):
|
||||||
|
with self.subTest(operations=operations):
|
||||||
|
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
|
||||||
|
with self.assertRaisesRegex(ValueError, "index is out of bounds"):
|
||||||
|
bulk_edit.edit_pdf([self.doc2.id], operations)
|
||||||
|
|
||||||
|
mock_open.assert_not_called()
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file.delay")
|
||||||
@mock.patch("documents.tasks.consume_file.apply_async")
|
@mock.patch("documents.tasks.consume_file.apply_async")
|
||||||
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
@mock.patch("documents.bulk_edit.tempfile.mkdtemp")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import warnings
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
@@ -11,6 +12,7 @@ from django.test import override_settings
|
|||||||
from documents.classifier import ClassifierModelCorruptError
|
from documents.classifier import ClassifierModelCorruptError
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
from documents.classifier import IncompatibleClassifierVersionError
|
from documents.classifier import IncompatibleClassifierVersionError
|
||||||
|
from documents.classifier import _predict_with_threshold
|
||||||
from documents.classifier import load_classifier
|
from documents.classifier import load_classifier
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -625,6 +627,103 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk)
|
self.assertEqual(self.classifier.predict_storage_path(doc1.content), sp.pk)
|
||||||
self.assertIsNone(self.classifier.predict_storage_path(doc2.content))
|
self.assertIsNone(self.classifier.predict_storage_path(doc2.content))
|
||||||
|
|
||||||
|
def test_predict_rejects_prediction_below_match_threshold(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Classifiers trained against test data with confident predictions
|
||||||
|
WHEN:
|
||||||
|
- CLASSIFIER_MATCH_THRESHOLD exceeds the model's confidence
|
||||||
|
THEN:
|
||||||
|
- Every predict_* method discards the match in favor of no match
|
||||||
|
"""
|
||||||
|
c1 = Correspondent.objects.create(
|
||||||
|
name="c1",
|
||||||
|
matching_algorithm=Correspondent.MATCH_AUTO,
|
||||||
|
)
|
||||||
|
dt1 = DocumentType.objects.create(
|
||||||
|
name="dt1",
|
||||||
|
matching_algorithm=DocumentType.MATCH_AUTO,
|
||||||
|
)
|
||||||
|
sp1 = StoragePath.objects.create(
|
||||||
|
name="sp1",
|
||||||
|
matching_algorithm=StoragePath.MATCH_AUTO,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc1 = Document.objects.create(
|
||||||
|
title="doc1",
|
||||||
|
content="this is a document from c1",
|
||||||
|
correspondent=c1,
|
||||||
|
document_type=dt1,
|
||||||
|
storage_path=sp1,
|
||||||
|
checksum="A",
|
||||||
|
)
|
||||||
|
Document.objects.create(
|
||||||
|
title="doc2",
|
||||||
|
content="this is a document from no one",
|
||||||
|
checksum="B",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.classifier.train()
|
||||||
|
|
||||||
|
predictors = {
|
||||||
|
"correspondent": self.classifier.predict_correspondent,
|
||||||
|
"document_type": self.classifier.predict_document_type,
|
||||||
|
"storage_path": self.classifier.predict_storage_path,
|
||||||
|
}
|
||||||
|
# No real prediction can reach a confidence this high, so this
|
||||||
|
# isolates the threshold check from the model's actual output.
|
||||||
|
with override_settings(CLASSIFIER_MATCH_THRESHOLD=0.999999):
|
||||||
|
for name, predict in predictors.items():
|
||||||
|
with self.subTest(field=name):
|
||||||
|
self.assertIsNone(predict(doc1.content))
|
||||||
|
|
||||||
|
def test_train_uses_balanced_sample_weight(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A training set with correspondents, document types and storage paths
|
||||||
|
WHEN:
|
||||||
|
- The classifier is trained
|
||||||
|
THEN:
|
||||||
|
- Each MLP classifier is fit with balanced sample weights, so that
|
||||||
|
over-represented classes don't dominate predictions
|
||||||
|
"""
|
||||||
|
c1 = Correspondent.objects.create(
|
||||||
|
name="c1",
|
||||||
|
matching_algorithm=Correspondent.MATCH_AUTO,
|
||||||
|
)
|
||||||
|
dt1 = DocumentType.objects.create(
|
||||||
|
name="dt1",
|
||||||
|
matching_algorithm=DocumentType.MATCH_AUTO,
|
||||||
|
)
|
||||||
|
sp1 = StoragePath.objects.create(
|
||||||
|
name="sp1",
|
||||||
|
matching_algorithm=StoragePath.MATCH_AUTO,
|
||||||
|
)
|
||||||
|
|
||||||
|
Document.objects.create(
|
||||||
|
title="doc1",
|
||||||
|
content="this is a document from c1",
|
||||||
|
correspondent=c1,
|
||||||
|
document_type=dt1,
|
||||||
|
storage_path=sp1,
|
||||||
|
checksum="A",
|
||||||
|
)
|
||||||
|
Document.objects.create(
|
||||||
|
title="doc2",
|
||||||
|
content="this is a document from no one",
|
||||||
|
checksum="B",
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"sklearn.utils.class_weight.compute_sample_weight",
|
||||||
|
return_value=None,
|
||||||
|
) as mocked_compute_sample_weight:
|
||||||
|
self.classifier.train()
|
||||||
|
|
||||||
|
self.assertEqual(mocked_compute_sample_weight.call_count, 3)
|
||||||
|
for call in mocked_compute_sample_weight.call_args_list:
|
||||||
|
self.assertEqual(call.args[0], "balanced")
|
||||||
|
|
||||||
def test_one_tag_predict(self) -> None:
|
def test_one_tag_predict(self) -> None:
|
||||||
t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12)
|
t1 = Tag.objects.create(name="t1", matching_algorithm=Tag.MATCH_AUTO, pk=12)
|
||||||
|
|
||||||
@@ -810,6 +909,52 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
|||||||
load_classifier(raise_exception=True)
|
load_classifier(raise_exception=True)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubProbaClassifier:
|
||||||
|
"""
|
||||||
|
A fake scikit-learn classifier exposing just enough of the API for
|
||||||
|
`_predict_with_threshold`: `classes_` and `predict_proba`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, classes: list[int], probabilities: list[float]) -> None:
|
||||||
|
self.classes_ = np.array(classes)
|
||||||
|
self._probabilities = np.array([probabilities])
|
||||||
|
|
||||||
|
def predict_proba(self, X) -> np.ndarray:
|
||||||
|
return self._probabilities
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("classes", "probabilities", "threshold", "expected"),
|
||||||
|
[
|
||||||
|
# confident prediction above the threshold is returned
|
||||||
|
([-1, 3], [0.1, 0.9], 0.6, 3),
|
||||||
|
# prediction below the threshold is discarded
|
||||||
|
([-1, 3], [0.45, 0.55], 0.6, None),
|
||||||
|
# boundary: exactly at the threshold is accepted, not discarded
|
||||||
|
([-1, 3], [0.4, 0.6], 0.6, 3),
|
||||||
|
# the winning class is the "no match" pseudo-class, regardless of its
|
||||||
|
# own confidence
|
||||||
|
([-1, 3], [0.99, 0.01], 0.0, None),
|
||||||
|
# threshold of 0.0 disables the confidence check entirely
|
||||||
|
([-1, 3], [0.45, 0.55], 0.0, 3),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_predict_with_threshold(classes, probabilities, threshold, expected) -> None:
|
||||||
|
classifier = _StubProbaClassifier(classes, probabilities)
|
||||||
|
result = _predict_with_threshold(classifier, X=None, threshold=threshold)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_match_threshold_default() -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- No PAPERLESS_CLASSIFIER_MATCH_THRESHOLD environment variable is set
|
||||||
|
THEN:
|
||||||
|
- The classifier match threshold defaults to 0.6
|
||||||
|
"""
|
||||||
|
assert settings.CLASSIFIER_MATCH_THRESHOLD == 0.6
|
||||||
|
|
||||||
|
|
||||||
def test_preprocess_content() -> None:
|
def test_preprocess_content() -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.db import connection
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.tests.factories import DocumentFactory
|
||||||
|
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||||
|
from documents.versioning import has_prefetched_effective_content
|
||||||
|
from documents.versioning import latest_version_content_prefetch
|
||||||
|
from documents.views import DocumentViewSet
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsEffectiveContentAnnotation:
|
||||||
|
"""
|
||||||
|
DocumentViewSet._needs_effective_content_annotation() decides whether
|
||||||
|
the effective_content correlated subquery is worth attaching to the
|
||||||
|
queryset at all -- see TestDocumentListEffectiveContentAnnotation below
|
||||||
|
for why. This only checks that decision's own logic (a plain query-param
|
||||||
|
membership test), not that Django/DRF's filtering machinery works.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("params", "expected"),
|
||||||
|
[
|
||||||
|
({}, False),
|
||||||
|
({"ordering": "-added"}, False),
|
||||||
|
({"tags__id__in": "1,2"}, False),
|
||||||
|
({"search": ""}, False),
|
||||||
|
({"search": " "}, False),
|
||||||
|
({"content__icontains": ""}, False),
|
||||||
|
({"search": "foo"}, True),
|
||||||
|
({"title_content": "foo"}, True),
|
||||||
|
({"content__istartswith": "foo"}, True),
|
||||||
|
({"content__iendswith": "foo"}, True),
|
||||||
|
({"content__icontains": "foo"}, True),
|
||||||
|
({"content__iexact": "foo"}, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_detects_content_filter_params(
|
||||||
|
self,
|
||||||
|
params: dict[str, str],
|
||||||
|
expected: bool, # noqa: FBT001
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A view bound to a request carrying the given query params
|
||||||
|
WHEN:
|
||||||
|
- Checking whether the effective_content annotation is needed
|
||||||
|
THEN:
|
||||||
|
- It is needed only for requests that actually filter on it
|
||||||
|
"""
|
||||||
|
view = DocumentViewSet()
|
||||||
|
view.request = SimpleNamespace(query_params=params)
|
||||||
|
|
||||||
|
assert view._needs_effective_content_annotation() is expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestNeedsEffectiveContentPrefetch:
|
||||||
|
"""
|
||||||
|
DocumentViewSet._needs_effective_content_prefetch() decides whether the
|
||||||
|
single-version content prefetch is worth attaching. It has to read the
|
||||||
|
`fields` param exactly the way get_serializer() does, or a request whose
|
||||||
|
response includes content ends up without the prefetch and pays
|
||||||
|
get_effective_content()'s per-instance fallback instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("params", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param({}, True, id="no-fields-param-keeps-every-field"),
|
||||||
|
pytest.param({"fields": ""}, True, id="blank-fields-keeps-every-field"),
|
||||||
|
pytest.param(
|
||||||
|
{"fields": "id,content"},
|
||||||
|
True,
|
||||||
|
id="content-among-requested-fields",
|
||||||
|
),
|
||||||
|
pytest.param({"fields": "content"}, True, id="content-only"),
|
||||||
|
pytest.param({"fields": "id"}, False, id="content-not-requested"),
|
||||||
|
pytest.param(
|
||||||
|
{"fields": "id,title"},
|
||||||
|
False,
|
||||||
|
id="several-fields-without-content",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_detects_whether_content_can_reach_the_response(
|
||||||
|
self,
|
||||||
|
params: dict[str, str],
|
||||||
|
expected: bool, # noqa: FBT001
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A view bound to a request carrying the given query params
|
||||||
|
WHEN:
|
||||||
|
- Checking whether the content prefetch is needed
|
||||||
|
THEN:
|
||||||
|
- It is needed exactly when get_serializer() would emit content,
|
||||||
|
which treats a blank `fields` the same as an absent one
|
||||||
|
"""
|
||||||
|
view = DocumentViewSet()
|
||||||
|
view.request = SimpleNamespace(query_params=params)
|
||||||
|
|
||||||
|
assert view._needs_effective_content_prefetch() is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestDocumentListEffectiveContentAnnotation:
|
||||||
|
"""
|
||||||
|
DocumentViewSet.get_queryset() only attaches the effective_content
|
||||||
|
correlated subquery when a request actually filters on it. Attaching it
|
||||||
|
unconditionally re-executes it once per candidate row before the page's
|
||||||
|
LIMIT is applied -- fine on SQLite/Postgres, but pathological on
|
||||||
|
MariaDB's default cardinality estimation for the root_document_id
|
||||||
|
self-join once candidate counts get large (see the root_document_id /
|
||||||
|
effective_content perf investigation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_list_without_content_filter_skips_annotation_but_returns_latest_content(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document whose latest version has different content
|
||||||
|
WHEN:
|
||||||
|
- Listing documents with no search/content-filter param
|
||||||
|
THEN:
|
||||||
|
- The response still reflects the latest version's content
|
||||||
|
- The database never evaluates effective_content per row
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="old-root-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="new-version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = admin_client.get("/api/documents/?fields=id,content")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["results"] == [
|
||||||
|
{"id": root.id, "content": "new-version-content"},
|
||||||
|
]
|
||||||
|
assert not any(
|
||||||
|
"effective_content" in query["sql"] for query in ctx.captured_queries
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"fields_param",
|
||||||
|
[
|
||||||
|
pytest.param("", id="blank-fields"),
|
||||||
|
pytest.param("id,content", id="content-requested"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_content_resolves_without_a_query_per_document(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
fields_param: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- One versioned root document, then two more
|
||||||
|
WHEN:
|
||||||
|
- Listing documents with a `fields` param that keeps content
|
||||||
|
THEN:
|
||||||
|
- Every root's content resolves to its latest version's
|
||||||
|
- The query count does not grow with the number of documents,
|
||||||
|
i.e. a blank `fields` does not skip the prefetch and fall back
|
||||||
|
to loading each root's deferred version content
|
||||||
|
"""
|
||||||
|
first = DocumentFactory(content="first-root-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=first,
|
||||||
|
version_index=1,
|
||||||
|
content="first-version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as one_document:
|
||||||
|
response = admin_client.get(f"/api/documents/?fields={fields_param}")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert [r["content"] for r in response.data["results"]] == [
|
||||||
|
"first-version-content",
|
||||||
|
]
|
||||||
|
|
||||||
|
for index in range(2):
|
||||||
|
root = DocumentFactory(content=f"root-content-{index}")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content=f"version-content-{index}",
|
||||||
|
)
|
||||||
|
with CaptureQueriesContext(connection) as three_documents:
|
||||||
|
response = admin_client.get(f"/api/documents/?fields={fields_param}")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert sorted(r["content"] for r in response.data["results"]) == [
|
||||||
|
"first-version-content",
|
||||||
|
"version-content-0",
|
||||||
|
"version-content-1",
|
||||||
|
]
|
||||||
|
assert len(_get_document_queries(three_documents)) == len(
|
||||||
|
_get_document_queries(one_document),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_without_content_field_skips_prefetch_and_omits_content(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A versioned root document
|
||||||
|
WHEN:
|
||||||
|
- Listing documents without asking for content
|
||||||
|
THEN:
|
||||||
|
- Content is neither serialized nor resolved
|
||||||
|
- Nothing pays for the prefetch or the per-instance fallback
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="root-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = admin_client.get("/api/documents/?fields=id")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["results"] == [{"id": root.id}]
|
||||||
|
assert _get_effective_content_fallback_queries(ctx) == []
|
||||||
|
# Only the list query itself reads a content column: no extra query
|
||||||
|
# for the skipped prefetch, none for a per-instance fallback
|
||||||
|
content_queries = [
|
||||||
|
query
|
||||||
|
for query in ctx.captured_queries
|
||||||
|
if '"documents_document"."content"' in query["sql"]
|
||||||
|
]
|
||||||
|
assert len(content_queries) == 1
|
||||||
|
|
||||||
|
def test_latest_version_content_prefetch_carries_only_the_newest_version(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with two versions
|
||||||
|
WHEN:
|
||||||
|
- Fetching the root through latest_version_content_prefetch()
|
||||||
|
THEN:
|
||||||
|
- The prefetch carries only the single newest version, not every
|
||||||
|
historical version's content (the whole point of not reusing
|
||||||
|
the metadata-only "versions" prefetch for this)
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="root-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="older-version-content",
|
||||||
|
)
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=2,
|
||||||
|
content="newest-version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched_root = (
|
||||||
|
Document.objects.filter(pk=root.pk)
|
||||||
|
.prefetch_related(
|
||||||
|
latest_version_content_prefetch(),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
)
|
||||||
|
|
||||||
|
latest = getattr(fetched_root, LATEST_VERSION_CONTENT_PREFETCH_ATTR)
|
||||||
|
assert [v.content for v in latest] == ["newest-version-content"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasPrefetchedEffectiveContent:
|
||||||
|
"""
|
||||||
|
DocumentSerializer.to_representation() only calls get_effective_content()
|
||||||
|
when has_prefetched_effective_content() says it's cheap -- otherwise a
|
||||||
|
caller that never set up an annotation or prefetch (TrashView,
|
||||||
|
GlobalSearchView, which build their own querysets and don't display
|
||||||
|
content at all) would pay for a per-instance query nobody asked for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_false_with_no_annotation_or_prefetch(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document the ORM never annotated or prefetched for
|
||||||
|
WHEN:
|
||||||
|
- Asking whether its effective content is already resolved
|
||||||
|
THEN:
|
||||||
|
- It is not, so the serializer must leave it alone
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.build()
|
||||||
|
|
||||||
|
assert has_prefetched_effective_content(document) is False
|
||||||
|
|
||||||
|
def test_true_with_effective_content_annotation(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document carrying the queryset's effective_content annotation
|
||||||
|
WHEN:
|
||||||
|
- Asking whether its effective content is already resolved
|
||||||
|
THEN:
|
||||||
|
- It is, straight off the annotation
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.build()
|
||||||
|
document.effective_content = "resolved"
|
||||||
|
|
||||||
|
assert has_prefetched_effective_content(document) is True
|
||||||
|
|
||||||
|
def test_true_with_lean_prefetch_attr_even_when_empty(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document the lean content prefetch ran for, finding no versions
|
||||||
|
WHEN:
|
||||||
|
- Asking whether its effective content is already resolved
|
||||||
|
THEN:
|
||||||
|
- It is: an empty prefetch is an answer, not a missing one
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.build()
|
||||||
|
setattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, [])
|
||||||
|
|
||||||
|
assert has_prefetched_effective_content(document) is True
|
||||||
|
|
||||||
|
def test_true_with_metadata_versions_prefetch_cache(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document carrying only the metadata "versions" prefetch
|
||||||
|
WHEN:
|
||||||
|
- Asking whether its effective content is already resolved
|
||||||
|
THEN:
|
||||||
|
- It is, via get_effective_content()'s prefetch-cache branch
|
||||||
|
"""
|
||||||
|
document = DocumentFactory.build()
|
||||||
|
document._prefetched_objects_cache = {"versions": []}
|
||||||
|
|
||||||
|
assert has_prefetched_effective_content(document) is True
|
||||||
|
|
||||||
|
|
||||||
|
def _get_document_queries(
|
||||||
|
ctx: CaptureQueriesContext,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""
|
||||||
|
The queries a list request spends on the documents themselves, i.e.
|
||||||
|
everything but the one-time django_content_type lookup guardian's
|
||||||
|
permission filtering makes. That lookup is process-cached, and the
|
||||||
|
autouse fixture in conftest clears the cache before every test, so it
|
||||||
|
lands in whichever request happens to run first and never repeats --
|
||||||
|
counting it makes a request look like it costs one query more than the
|
||||||
|
identical request after it.
|
||||||
|
"""
|
||||||
|
return [q for q in ctx.captured_queries if '"django_content_type"' not in q["sql"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_effective_content_fallback_queries(
|
||||||
|
ctx: CaptureQueriesContext,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Document.get_effective_content()'s per-instance fallback (no annotation,
|
||||||
|
no prefetch) is a `.values_list("content", flat=True).first()` query --
|
||||||
|
a SELECT of just the content column. Distinct from get_versions()'s own,
|
||||||
|
unrelated per-instance metadata query (id/checksum/added/etc, no
|
||||||
|
content) run to build the "versions" response field, which isn't part
|
||||||
|
of what this test file covers.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
q
|
||||||
|
for q in ctx.captured_queries
|
||||||
|
if q["sql"].startswith('SELECT "documents_document"."content" FROM')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestTrashAndGlobalSearchEffectiveContentIsNeverPerInstance:
|
||||||
|
"""
|
||||||
|
TrashView and GlobalSearchView serialize Document instances with
|
||||||
|
DocumentSerializer too, but build their querysets independently of
|
||||||
|
DocumentViewSet.get_queryset(). TrashView doesn't display content at all,
|
||||||
|
so it keeps the document's own unresolved content; GlobalSearchView
|
||||||
|
annotates effective_content itself, so it shows the latest version's.
|
||||||
|
Neither should ever fall back to a per-instance query.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_trash_list_shows_unresolved_content_with_no_extra_query(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A trashed root document whose own content differs from what a
|
||||||
|
version would have had (also trashed, deletion cascades)
|
||||||
|
WHEN:
|
||||||
|
- Listing trash
|
||||||
|
THEN:
|
||||||
|
- The response shows the document's own content
|
||||||
|
- Nothing ever queries for versions to resolve it
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(content="own-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="version-content",
|
||||||
|
)
|
||||||
|
root.delete()
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = admin_client.get("/api/trash/")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
||||||
|
assert result["content"] == "own-content"
|
||||||
|
assert _get_effective_content_fallback_queries(ctx) == []
|
||||||
|
|
||||||
|
def test_global_search_db_only_shows_latest_version_content_with_no_extra_query(
|
||||||
|
self,
|
||||||
|
admin_client: APIClient,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document, findable by title, whose own content differs
|
||||||
|
from its latest version's
|
||||||
|
WHEN:
|
||||||
|
- Using the global search endpoint's db_only mode
|
||||||
|
THEN:
|
||||||
|
- The response shows the latest version's content, resolved by
|
||||||
|
GlobalSearchView's own effective_content annotation
|
||||||
|
- There is no per-instance fallback query
|
||||||
|
"""
|
||||||
|
root = DocumentFactory(title="findme", content="own-content")
|
||||||
|
DocumentFactory(
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="version-content",
|
||||||
|
)
|
||||||
|
|
||||||
|
with CaptureQueriesContext(connection) as ctx:
|
||||||
|
response = admin_client.get(
|
||||||
|
"/api/search/?query=findme&db_only=true",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
[result] = [d for d in response.data["documents"] if d["id"] == root.id]
|
||||||
|
assert result["content"] == "version-content"
|
||||||
|
assert _get_effective_content_fallback_queries(ctx) == []
|
||||||
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
|
|||||||
checksum="checksum",
|
checksum="checksum",
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
Document.objects.create(
|
version = Document.objects.create(
|
||||||
root_document=root,
|
root_document=root,
|
||||||
correspondent=root.correspondent,
|
correspondent=root.correspondent,
|
||||||
title="Version",
|
title="Version",
|
||||||
@@ -124,6 +124,10 @@ class TestDocument(TestCase):
|
|||||||
self.assertEqual(Document.objects.count(), 0)
|
self.assertEqual(Document.objects.count(), 0)
|
||||||
self.assertEqual(Document.deleted_objects.count(), 2)
|
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:
|
def test_file_name(self) -> None:
|
||||||
doc = Document(
|
doc = Document(
|
||||||
mime_type="application/pdf",
|
mime_type="application/pdf",
|
||||||
|
|||||||
@@ -136,6 +136,23 @@ def wait_for_mock_call(
|
|||||||
return False
|
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:
|
class TestTrackedFile:
|
||||||
"""Tests for the TrackedFile dataclass."""
|
"""Tests for the TrackedFile dataclass."""
|
||||||
|
|
||||||
@@ -261,6 +278,56 @@ class TestFileStabilityTracker:
|
|||||||
assert len(stable) == 0
|
assert len(stable) == 0
|
||||||
assert stability_tracker.pending_count == 1
|
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:
|
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
|
||||||
"""Test deleted file is not returned during stability check."""
|
"""Test deleted file is not returned during stability check."""
|
||||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||||
@@ -879,6 +946,51 @@ class TestCommandWatch:
|
|||||||
|
|
||||||
mock_consume_file_delay.apply_async.assert_called()
|
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(
|
def test_ignores_macos_files(
|
||||||
self,
|
self,
|
||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ class TestEmailDocumentPermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
hidden = DocumentFactory(owner=owner)
|
hidden = DocumentFactory(owner=owner)
|
||||||
|
|
||||||
@@ -364,6 +367,27 @@ class TestBulkEditChangePermissionBoundary:
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestBulkDownloadPermissionChecksRootDocument:
|
class TestBulkDownloadPermissionChecksRootDocument:
|
||||||
|
def test_download_requires_global_view_permission(
|
||||||
|
self,
|
||||||
|
rest_api_client,
|
||||||
|
paperless_dirs,
|
||||||
|
_media_settings,
|
||||||
|
):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
root = DocumentFactory(owner=owner)
|
||||||
|
root.source_path.write_bytes(b"%PDF-1.4 test")
|
||||||
|
assign_perm("view_document", requester, root)
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/documents/bulk_download/",
|
||||||
|
{"documents": [root.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
def test_permission_checked_on_root_not_on_version(
|
def test_permission_checked_on_root_not_on_version(
|
||||||
self,
|
self,
|
||||||
rest_api_client,
|
rest_api_client,
|
||||||
@@ -372,6 +396,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
root = DocumentFactory(owner=owner)
|
root = DocumentFactory(owner=owner)
|
||||||
# a version of root that the requester has NOT been individually granted
|
# a version of root that the requester has NOT been individually granted
|
||||||
@@ -396,6 +423,9 @@ class TestBulkDownloadPermissionChecksRootDocument:
|
|||||||
# `stranger` case) can't tell the two apart, since they're denied
|
# `stranger` case) can't tell the two apart, since they're denied
|
||||||
# either way.
|
# either way.
|
||||||
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
version_only_grantee = User.objects.create_user(username="version_only_grantee")
|
||||||
|
version_only_grantee.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
assign_perm("view_document", version_only_grantee, version)
|
assign_perm("view_document", version_only_grantee, version)
|
||||||
rest_api_client.force_authenticate(user=version_only_grantee)
|
rest_api_client.force_authenticate(user=version_only_grantee)
|
||||||
response = rest_api_client.post(
|
response = rest_api_client.post(
|
||||||
@@ -417,6 +447,9 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="delete_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("view_document", requester, doc) # view only, NOT delete
|
assign_perm("view_document", requester, doc) # view only, NOT delete
|
||||||
@@ -435,6 +468,9 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
):
|
):
|
||||||
owner = User.objects.create_user(username="owner")
|
owner = User.objects.create_user(username="owner")
|
||||||
requester = User.objects.create_user(username="requester")
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="delete_document"),
|
||||||
|
)
|
||||||
rest_api_client.force_authenticate(user=requester)
|
rest_api_client.force_authenticate(user=requester)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
assign_perm("delete_document", requester, doc)
|
assign_perm("delete_document", requester, doc)
|
||||||
@@ -447,6 +483,22 @@ class TestTrashRestorePermissionBoundary:
|
|||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
|
||||||
|
def test_restore_requires_global_delete_permission(self, rest_api_client):
|
||||||
|
owner = User.objects.create_user(username="owner")
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
rest_api_client.force_authenticate(user=requester)
|
||||||
|
doc = DocumentFactory(owner=owner)
|
||||||
|
assign_perm("delete_document", requester, doc)
|
||||||
|
doc.delete()
|
||||||
|
|
||||||
|
response = rest_api_client.post(
|
||||||
|
"/api/trash/",
|
||||||
|
{"documents": [doc.pk], "action": "restore"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
||||||
@@ -463,6 +515,9 @@ class TestTrashViewExcludesExplicitlyGrantedDocuments:
|
|||||||
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
def test_explicit_grant_does_not_leak_trashed_document(self, rest_api_client):
|
||||||
owner = User.objects.create_user(username="trash_owner")
|
owner = User.objects.create_user(username="trash_owner")
|
||||||
grantee = User.objects.create_user(username="trash_grantee")
|
grantee = User.objects.create_user(username="trash_grantee")
|
||||||
|
grantee.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
doc = DocumentFactory(owner=owner)
|
doc = DocumentFactory(owner=owner)
|
||||||
doc.delete() # soft delete
|
doc.delete() # soft delete
|
||||||
assign_perm("view_document", grantee, doc)
|
assign_perm("view_document", grantee, doc)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import regex
|
import regex
|
||||||
|
from django.conf import settings
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from documents.regex import safe_regex_finditer
|
from documents.regex import safe_regex_finditer
|
||||||
@@ -9,6 +10,12 @@ from documents.regex import safe_regex_sub
|
|||||||
from documents.regex import validate_regex_pattern
|
from documents.regex import validate_regex_pattern
|
||||||
|
|
||||||
|
|
||||||
|
def test_regex_timeout_uses_configured_setting() -> None:
|
||||||
|
from documents.regex import REGEX_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
assert REGEX_TIMEOUT_SECONDS == settings.MATCH_REGEX_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
class TestValidateRegexPattern:
|
class TestValidateRegexPattern:
|
||||||
def test_valid_pattern(self) -> None:
|
def test_valid_pattern(self) -> None:
|
||||||
validate_regex_pattern(r"\d+")
|
validate_regex_pattern(r"\d+")
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -48,6 +50,37 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
delay_mock.assert_called_once()
|
delay_mock.assert_called_once()
|
||||||
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
|
self.assertEqual(delay_mock.call_args.kwargs["kwargs"]["bundle_id"], bundle.pk)
|
||||||
|
|
||||||
|
@mock.patch("documents.views.build_share_link_bundle.apply_async")
|
||||||
|
def test_create_bundle_requires_global_document_view_permission(
|
||||||
|
self,
|
||||||
|
delay_mock,
|
||||||
|
) -> None:
|
||||||
|
owner = User.objects.create_user(username="document_owner")
|
||||||
|
requester = User.objects.create_user(username="bundle_creator")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="add_sharelinkbundle"),
|
||||||
|
)
|
||||||
|
document = DocumentFactory.create(owner=owner)
|
||||||
|
assign_perm("view_document", requester, document)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
payload = {
|
||||||
|
"document_ids": [document.pk],
|
||||||
|
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||||
|
"expiration_days": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
requester.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
|
requester = User.objects.get(pk=requester.pk)
|
||||||
|
self.client.force_authenticate(requester)
|
||||||
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
delay_mock.assert_called_once()
|
||||||
|
|
||||||
def test_create_bundle_rejects_missing_documents(self) -> None:
|
def test_create_bundle_rejects_missing_documents(self) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"document_ids": [9999],
|
"document_ids": [9999],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from unittest import mock
|
|||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents import bulk_edit
|
from documents import bulk_edit
|
||||||
@@ -108,6 +109,44 @@ class TestTagHierarchy(DirectoriesMixin, APITestCase):
|
|||||||
self.document.refresh_from_db()
|
self.document.refresh_from_db()
|
||||||
assert self.document.tags.count() == 0
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
def test_remove_inbox_tags_removes_nested_children(self) -> None:
|
||||||
|
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True)
|
||||||
|
nested = Tag.objects.create(name="Nested", tn_parent=inbox)
|
||||||
|
self.document.add_nested_tags([nested])
|
||||||
|
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{self.document.pk}/",
|
||||||
|
{"title": "new title", "remove_inbox_tags": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == status.HTTP_200_OK
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
# A subsequent save must not re-add the inbox tag as an ancestor
|
||||||
|
resp = self.client.patch(
|
||||||
|
f"/api/documents/{self.document.pk}/",
|
||||||
|
{"title": "another title", "tags": [], "remove_inbox_tags": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == status.HTTP_200_OK
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
def test_remove_inbox_tags_keeps_inbox_when_nested_child_added(self) -> None:
|
||||||
|
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True)
|
||||||
|
nested = Tag.objects.create(name="Nested", tn_parent=inbox)
|
||||||
|
self.document.add_nested_tags([inbox])
|
||||||
|
|
||||||
|
self.client.patch(
|
||||||
|
f"/api/documents/{self.document.pk}/",
|
||||||
|
{"tags": [nested.pk], "remove_inbox_tags": True},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
tags = set(self.document.tags.values_list("pk", flat=True))
|
||||||
|
assert tags == {inbox.pk, nested.pk}
|
||||||
|
|
||||||
def test_bulk_edit_respects_hierarchy(self) -> None:
|
def test_bulk_edit_respects_hierarchy(self) -> None:
|
||||||
bulk_edit.add_tag([self.document.pk], self.child.pk)
|
bulk_edit.add_tag([self.document.pk], self.child.pk)
|
||||||
self.document.refresh_from_db()
|
self.document.refresh_from_db()
|
||||||
|
|||||||
@@ -106,6 +106,17 @@ class TestBeforeTaskPublishHandler:
|
|||||||
assert task.task_type == PaperlessTask.TaskType.TRAIN_CLASSIFIER
|
assert task.task_type == PaperlessTask.TaskType.TRAIN_CLASSIFIER
|
||||||
assert task.trigger_source == PaperlessTask.TriggerSource.MANUAL
|
assert task.trigger_source == PaperlessTask.TriggerSource.MANUAL
|
||||||
|
|
||||||
|
# A Celery retry republishes with the same task_id; this must not
|
||||||
|
# raise a duplicate-key IntegrityError, and must leave the original
|
||||||
|
# PENDING record alone.
|
||||||
|
send_publish(
|
||||||
|
"documents.tasks.train_classifier",
|
||||||
|
(),
|
||||||
|
{},
|
||||||
|
headers={"id": task_id},
|
||||||
|
)
|
||||||
|
assert PaperlessTask.objects.filter(task_id=task_id).count() == 1
|
||||||
|
|
||||||
def test_creates_task_for_sanity_check(self) -> None:
|
def test_creates_task_for_sanity_check(self) -> None:
|
||||||
task_id = send_publish("documents.tasks.sanity_check", (), {})
|
task_id = send_publish("documents.tasks.sanity_check", (), {})
|
||||||
task = PaperlessTask.objects.get(task_id=task_id)
|
task = PaperlessTask.objects.get(task_id=task_id)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from documents.signals.handlers import update_llm_suggestions_cache
|
|||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from documents.tests.utils import read_streaming_response
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
|
from paperless_ai.exceptions import LLMProviderError
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
|
|
||||||
@@ -140,6 +141,9 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
codename__contains="sharelink",
|
codename__contains="sharelink",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(*sharelink_permissions)
|
self.user.user_permissions.add(*sharelink_permissions)
|
||||||
|
self.user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.user.save()
|
self.user.save()
|
||||||
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
@@ -201,6 +205,9 @@ class TestViews(DirectoriesMixin, TestCase):
|
|||||||
codename__contains="sharelink",
|
codename__contains="sharelink",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(*sharelink_permissions)
|
self.user.user_permissions.add(*sharelink_permissions)
|
||||||
|
self.user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_document"),
|
||||||
|
)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
create_response = self.client.post(
|
create_response = self.client.post(
|
||||||
@@ -737,6 +744,38 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
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")
|
@patch("documents.views.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user