mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-09 22:26:05 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e427818360 | ||
|
|
2f623bad7b | ||
|
|
6a162abbfa | ||
|
|
0bd07aa61d | ||
|
|
358a291455 | ||
|
|
cfa997b167 | ||
|
|
e52956c382 | ||
|
|
f13f83aff5 | ||
|
|
631b19ef56 | ||
|
|
14ec3a2582 | ||
|
|
02a3d5ebe1 | ||
|
|
a07a2f15fb | ||
|
|
570a4af601 | ||
|
|
dc02ca4707 | ||
|
|
ee55d0adca | ||
|
|
bc89e00771 | ||
|
|
27bf4b05fa |
No files matched your search
@@ -56,9 +56,6 @@ We may close reports that are:
|
||||
|
||||
The following are not generally considered vulnerabilities unless accompanied by a concrete, reproducible impact in Paperless-ngx:
|
||||
|
||||
- password length inferred from masked mail-account passwords by users who already have permission to view the account, without disclosure of the password or token itself
|
||||
- disclosure limited to document IDs, existence, page counts, checksums, or modification/deletion timestamps, without access to document contents or an unintended privileged action
|
||||
- authenticated access to non-secret application settings, such as service endpoint URLs, model names, or configured environment variable names, without disclosure of credentials or secret values
|
||||
- large uploads or resource usage that do not bypass documented limits or privileges
|
||||
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
||||
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
||||
|
||||
+11
-18
@@ -4030,21 +4030,14 @@
|
||||
<source>Sources</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||
<context context-type="linenumber">47,48</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6025275430560167531" datatype="html">
|
||||
<source>+<x id="INTERPOLATION" equiv-text="{{ hiddenReferenceCount(message) }}"/> more</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||
<context context-type="linenumber">49,50</context>
|
||||
<context context-type="linenumber">46,47</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8066608938393600549" datatype="html">
|
||||
<source>Message</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||
<context context-type="linenumber">66,67</context>
|
||||
<context context-type="linenumber">61,62</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
|
||||
@@ -4055,22 +4048,22 @@
|
||||
<source>Stop</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||
<context context-type="linenumber">79</context>
|
||||
<context context-type="linenumber">74</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||
<context context-type="linenumber">79</context>
|
||||
<context context-type="linenumber">74</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6490688569532630280" datatype="html">
|
||||
<source>Send</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||
<context context-type="linenumber">83</context>
|
||||
<context context-type="linenumber">78</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||
<context context-type="linenumber">83</context>
|
||||
<context context-type="linenumber">78</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
@@ -4097,14 +4090,14 @@
|
||||
<source>Ask a question about this document...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context>
|
||||
<context context-type="linenumber">83</context>
|
||||
<context context-type="linenumber">61</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="224049052163028286" datatype="html">
|
||||
<source>Ask a question about a document...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context>
|
||||
<context context-type="linenumber">84</context>
|
||||
<context context-type="linenumber">62</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8700121026680200191" datatype="html">
|
||||
@@ -6991,7 +6984,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context>
|
||||
<context context-type="linenumber">185</context>
|
||||
<context context-type="linenumber">179</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1880237574877817137" datatype="html">
|
||||
@@ -7098,7 +7091,7 @@
|
||||
<source>Private</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context>
|
||||
<context context-type="linenumber">73</context>
|
||||
<context context-type="linenumber">72</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/tag/tag.component.html</context>
|
||||
@@ -7121,7 +7114,7 @@
|
||||
<source>No items found</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context>
|
||||
<context context-type="linenumber">114</context>
|
||||
<context context-type="linenumber">113</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6541407358060244620" datatype="html">
|
||||
|
||||
@@ -7,10 +7,15 @@ import {
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
ActivatedRoute,
|
||||
convertToParamMap,
|
||||
ParamMap,
|
||||
Router,
|
||||
} from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { BehaviorSubject, of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import {
|
||||
PaperlessTask,
|
||||
@@ -159,6 +164,7 @@ const sectionCountResponse = {
|
||||
}
|
||||
|
||||
describe('TasksComponent', () => {
|
||||
let queryParams: BehaviorSubject<ParamMap>
|
||||
let component: TasksComponent
|
||||
let fixture: ComponentFixture<TasksComponent>
|
||||
let tasksService: TasksService
|
||||
@@ -169,6 +175,7 @@ describe('TasksComponent', () => {
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
queryParams = new BehaviorSubject(convertToParamMap({}))
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbModule,
|
||||
@@ -181,6 +188,17 @@ describe('TasksComponent', () => {
|
||||
ConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: queryParams.asObservable(),
|
||||
snapshot: {
|
||||
get queryParamMap() {
|
||||
return queryParams.value
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
@@ -238,6 +256,71 @@ describe('TasksComponent', () => {
|
||||
flushTaskLists(25, count)
|
||||
}
|
||||
|
||||
it('consumes a task link on load into the normal resettable Task ID filter', () => {
|
||||
jest
|
||||
.spyOn(tasksService, 'list')
|
||||
.mockReturnValue(of({ count: 0, results: [] }))
|
||||
jest
|
||||
.spyOn(tasksService, 'statusCounts')
|
||||
.mockReturnValue(of(sectionCountResponse))
|
||||
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
||||
queryParams.next(convertToParamMap({ task_id: 'linked-task' }))
|
||||
fixture.destroy()
|
||||
fixture = TestBed.createComponent(TasksComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
expect(router.navigate).toHaveBeenCalledTimes(1)
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
[],
|
||||
expect.objectContaining({
|
||||
queryParams: { task_id: null },
|
||||
replaceUrl: true,
|
||||
})
|
||||
)
|
||||
expect(component.filterTargetName).toBe('Task ID')
|
||||
expect(component.filterText).toBe('linked-task')
|
||||
expect(component.isFiltered).toBe(true)
|
||||
component.setSection(TaskSection.InProgress)
|
||||
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
|
||||
task_id: 'linked-task',
|
||||
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
|
||||
})
|
||||
component.resetFilters()
|
||||
expect(component.filterText).toBe('')
|
||||
expect(component.isFiltered).toBe(false)
|
||||
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
|
||||
acknowledged: false,
|
||||
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
|
||||
})
|
||||
expect(router.navigate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('supports manually filtering by Task ID and clearing the text filter', () => {
|
||||
jest
|
||||
.spyOn(tasksService, 'list')
|
||||
.mockReturnValue(of({ count: 0, results: [] }))
|
||||
jest
|
||||
.spyOn(tasksService, 'statusCounts')
|
||||
.mockReturnValue(of(sectionCountResponse))
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setFilterTarget(TaskFilterTargetID.TaskId)
|
||||
component.filterText = tasks[0].task_id
|
||||
jest.advanceTimersByTime(150)
|
||||
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
|
||||
task_id: tasks[0].task_id,
|
||||
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
|
||||
})
|
||||
component.pagedTasks.set(tasks)
|
||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toEqual([
|
||||
tasks[0],
|
||||
])
|
||||
component.resetFilter()
|
||||
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
|
||||
acknowledged: false,
|
||||
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
|
||||
})
|
||||
})
|
||||
|
||||
it('should display task sections with counts', () => {
|
||||
expect(component.selectedSection).toBe(TaskSection.All)
|
||||
expect(component.selectedTaskType).toBeNull()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { Router, RouterLink } from '@angular/router'
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
||||
import {
|
||||
NgbCollapseModule,
|
||||
NgbDropdownModule,
|
||||
@@ -47,11 +47,13 @@ export enum TaskSection {
|
||||
export enum TaskFilterTargetID {
|
||||
Name,
|
||||
Result,
|
||||
TaskId,
|
||||
}
|
||||
|
||||
const FILTER_TARGETS = [
|
||||
{ id: TaskFilterTargetID.Name, name: $localize`Name` },
|
||||
{ id: TaskFilterTargetID.Result, name: $localize`Result` },
|
||||
{ id: TaskFilterTargetID.TaskId, name: $localize`Task ID` },
|
||||
]
|
||||
|
||||
const SECTION_LABELS = {
|
||||
@@ -161,6 +163,7 @@ export class TasksComponent
|
||||
tasksService = inject(TasksService)
|
||||
private modalService = inject(NgbModal)
|
||||
private readonly router = inject(Router)
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly TaskSection = TaskSection
|
||||
@@ -275,6 +278,17 @@ export class TasksComponent
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const taskId = this.route.snapshot.queryParamMap.get('task_id')
|
||||
if (taskId) {
|
||||
this.filterTargetID = TaskFilterTargetID.TaskId
|
||||
this._filterText = taskId
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { task_id: null },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
})
|
||||
}
|
||||
this.tasksService.reload()
|
||||
this.reloadPage()
|
||||
timer(5000, 5000)
|
||||
@@ -636,6 +650,9 @@ export class TasksComponent
|
||||
|
||||
const query = this._filterText.toLowerCase()
|
||||
|
||||
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
|
||||
return task.task_id.toLowerCase() === query
|
||||
}
|
||||
if (this.filterTargetID == TaskFilterTargetID.Name) {
|
||||
return [
|
||||
this.taskDisplayName(task),
|
||||
@@ -706,9 +723,15 @@ export class TasksComponent
|
||||
}
|
||||
|
||||
if (this._filterText.length) {
|
||||
params[
|
||||
this.filterTargetID === TaskFilterTargetID.Name ? 'name' : 'result'
|
||||
] = this._filterText
|
||||
const parameter = {
|
||||
[TaskFilterTargetID.Name]: 'name',
|
||||
[TaskFilterTargetID.Result]: 'result',
|
||||
[TaskFilterTargetID.TaskId]: 'task_id',
|
||||
}[this.filterTargetID]
|
||||
params[parameter] = this._filterText
|
||||
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
|
||||
delete params.acknowledged
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
|
||||
@@ -11,8 +11,10 @@ import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { of, Subject, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { BulkOperationStatus } from 'src/app/data/bulk-operation'
|
||||
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
@@ -21,10 +23,11 @@ import {
|
||||
DjangoMessageLevel,
|
||||
DjangoMessagesService,
|
||||
} from 'src/app/services/django-messages.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import {
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
@@ -95,6 +98,7 @@ describe('AppFrameComponent', () => {
|
||||
let savedViewSpy
|
||||
let modalService: NgbModal
|
||||
let maybeRefreshSpy
|
||||
let bulkEvents: Subject<BulkOperationStatus>
|
||||
let tasksService: TasksService
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -182,6 +186,10 @@ describe('AppFrameComponent', () => {
|
||||
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
||||
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
|
||||
|
||||
bulkEvents = new Subject<BulkOperationStatus>()
|
||||
jest
|
||||
.spyOn(TestBed.inject(WebsocketStatusService), 'onBulkOperationStatus')
|
||||
.mockReturnValue(bulkEvents.asObservable())
|
||||
fixture = TestBed.createComponent(AppFrameComponent)
|
||||
component = fixture.componentInstance
|
||||
|
||||
@@ -190,6 +198,71 @@ describe('AppFrameComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('refreshes and notifies on completion without client-side task tracking', () => {
|
||||
const list = TestBed.inject(DocumentListViewService)
|
||||
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||
const tasksReload = jest
|
||||
.spyOn(tasksService, 'reload')
|
||||
.mockImplementation(() => {})
|
||||
const success = jest.spyOn(toastService, 'showSuccess')
|
||||
const refresh = jest
|
||||
.spyOn(openDocumentsService, 'refreshDocument')
|
||||
.mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(openDocumentsService, 'getOpenDocuments')
|
||||
.mockReturnValue([document])
|
||||
tasksReload.mockClear()
|
||||
maybeRefreshSpy.mockClear()
|
||||
bulkEvents.next({
|
||||
task_id: 'bulk-task',
|
||||
operation: 'set_permissions',
|
||||
status: PaperlessTaskStatus.Started,
|
||||
})
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
bulkEvents.next({
|
||||
task_id: 'bulk-task',
|
||||
operation: 'set_permissions',
|
||||
status: PaperlessTaskStatus.Success,
|
||||
})
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
expect(reload).toHaveBeenCalledWith(null, false)
|
||||
expect(refresh).toHaveBeenCalledWith(document.id)
|
||||
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
|
||||
expect(tasksReload).toHaveBeenCalledTimes(1)
|
||||
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
|
||||
fixture.destroy()
|
||||
bulkEvents.next({
|
||||
task_id: 'bulk-task',
|
||||
operation: 'set_permissions',
|
||||
status: PaperlessTaskStatus.Success,
|
||||
})
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refreshes and reports failed or revoked bulk operations', () => {
|
||||
const list = TestBed.inject(DocumentListViewService)
|
||||
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
||||
const error = jest.spyOn(toastService, 'showError')
|
||||
const success = jest.spyOn(toastService, 'showSuccess')
|
||||
for (const status of [
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
]) {
|
||||
bulkEvents.next({
|
||||
task_id: 'bulk-task',
|
||||
operation: 'set_permissions',
|
||||
status,
|
||||
error: 'Could not update',
|
||||
})
|
||||
}
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should initialize the saved view service', () => {
|
||||
expect(savedViewSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
import { NgClass } from '@angular/common'
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core'
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
@@ -24,10 +26,11 @@ import {
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Observable } from 'rxjs'
|
||||
import { filter, Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import {
|
||||
CollapsibleSection,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
DjangoMessageLevel,
|
||||
DjangoMessagesService,
|
||||
} from 'src/app/services/django-messages.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
@@ -101,6 +105,8 @@ export class AppFrameComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, ComponentCanDeactivate
|
||||
{
|
||||
private destroyRef = inject(DestroyRef)
|
||||
private documentList = inject(DocumentListViewService)
|
||||
router = inject(Router)
|
||||
private activatedRoute = inject(ActivatedRoute)
|
||||
private openDocumentsService = inject(OpenDocumentsService)
|
||||
@@ -164,6 +170,44 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.websocketStatusService
|
||||
.onBulkOperationStatus()
|
||||
.pipe(
|
||||
filter(({ status }) =>
|
||||
[
|
||||
PaperlessTaskStatus.Success,
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
].includes(status)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(({ status, error }) => {
|
||||
this.documentList.reload(null, false)
|
||||
this.documentList.reduceSelectionToFilter()
|
||||
// maybe someday we can optimize to only refresh affected documents
|
||||
this.openDocumentsService.getOpenDocuments().forEach((doc) => {
|
||||
this.openDocumentsService.refreshDocument(doc.id)
|
||||
})
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.PaperlessTask
|
||||
)
|
||||
) {
|
||||
this.tasksService.reload()
|
||||
}
|
||||
if (status === PaperlessTaskStatus.Success) {
|
||||
this.toastService.showSuccess($localize`Bulk operation completed.`)
|
||||
} else {
|
||||
this.toastService.showError(
|
||||
$localize`Bulk operation failed. Some changes may already have been applied.`,
|
||||
error
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.lastScrollY = window.scrollY
|
||||
this.detectClassicScrollbars()
|
||||
|
||||
|
||||
@@ -42,16 +42,11 @@
|
||||
<span class="text-break">{{ message.content }}</span>
|
||||
}
|
||||
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
||||
<div class="chat-references mt-2 pt-2 border-top">
|
||||
<div class="d-flex align-items-center text-muted mb-1">
|
||||
<span i18n>Sources</span>
|
||||
@if (hiddenReferenceCount(message) > 0 && !expandedReferences().has(message)) {
|
||||
<button type="button" class="badge rounded-pill bg-body-secondary text-body-secondary fw-normal border-0 ms-2" (click)="expandReferences(message)" i18n>+{{ hiddenReferenceCount(message) }} more</button>
|
||||
}
|
||||
</div>
|
||||
@for (reference of visibleReferences(message); track reference.id) {
|
||||
<a class="reference-link d-flex align-items-center lh-sm text-primary text-decoration-none py-1" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||
<pngx-bs-icon width="0.8em" height="0.8em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
|
||||
<div class="chat-references list-group mt-3">
|
||||
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
||||
@for (reference of message.references; track reference.id) {
|
||||
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||
<pngx-bs-icon width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message .flex-grow-1 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
white-space: initial;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon
|
||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatReference,
|
||||
ChatService,
|
||||
parseChatResponse,
|
||||
} from 'src/app/services/chat.service'
|
||||
@@ -41,29 +40,8 @@ export class ChatComponent implements OnInit {
|
||||
readonly input = signal('')
|
||||
readonly documentId = signal<number>(undefined)
|
||||
readonly slowResponse = signal(false)
|
||||
readonly expandedReferences = signal<Set<ChatMessage>>(new Set())
|
||||
public popperOptions = pngxPopperOptions
|
||||
|
||||
static readonly COLLAPSED_REFERENCE_COUNT = 2
|
||||
|
||||
public visibleReferences(message: ChatMessage): ChatReference[] {
|
||||
if (this.expandedReferences().has(message)) {
|
||||
return message.references
|
||||
}
|
||||
return message.references.slice(0, ChatComponent.COLLAPSED_REFERENCE_COUNT)
|
||||
}
|
||||
|
||||
public hiddenReferenceCount(message: ChatMessage): number {
|
||||
return Math.max(
|
||||
0,
|
||||
message.references.length - ChatComponent.COLLAPSED_REFERENCE_COUNT
|
||||
)
|
||||
}
|
||||
|
||||
public expandReferences(message: ChatMessage): void {
|
||||
this.expandedReferences.update((expanded) => new Set(expanded).add(message))
|
||||
}
|
||||
|
||||
private chatService: ChatService = inject(ChatService)
|
||||
private router: Router = inject(Router)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
[appendTo]="appendTo"
|
||||
[loading]="loading()"
|
||||
[typeahead]="documentsInput$"
|
||||
(mousedown)="appendTo && $event.stopImmediatePropagation()"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
(change)="onChange(selectedDocumentIDs)">
|
||||
<ng-template ng-label-tmp let-document="item">
|
||||
<div class="d-flex align-items-center">
|
||||
|
||||
@@ -97,7 +97,6 @@ describe('SelectComponent', () => {
|
||||
let createNewVal
|
||||
component.createNew.subscribe((v) => (createNewVal = v))
|
||||
expect(component.allowCreateNew).toBeTruthy()
|
||||
const closeSpy = jest.spyOn(component.ngSelectComponent, 'close')
|
||||
component.onSearch({ term: 'foo' })
|
||||
component.addItem(undefined)
|
||||
expect(createNewVal).toEqual('foo')
|
||||
@@ -106,8 +105,6 @@ describe('SelectComponent', () => {
|
||||
component.onSearch({ term: 'baz' })
|
||||
component.clickNew()
|
||||
expect(createNewVal).toEqual('baz')
|
||||
// dropdown should close when creating a new item so it isn't left open behind the modal
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should search items by independent normalized terms', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
forwardRef,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
FormsModule,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||
import { matchesSearchText } from 'src/app/utils/text-search'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
@@ -125,9 +124,6 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
|
||||
@Output()
|
||||
filterDocuments = new EventEmitter<any[]>()
|
||||
|
||||
@ViewChild(NgSelectComponent)
|
||||
ngSelectComponent: NgSelectComponent
|
||||
|
||||
public addItemRef: (name) => void
|
||||
|
||||
private _lastSearchTerm: string
|
||||
@@ -154,13 +150,11 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
|
||||
if (name) this.createNew.next(name)
|
||||
else this.createNew.next(this._lastSearchTerm)
|
||||
this.clearLastSearchTerm()
|
||||
this.ngSelectComponent?.close()
|
||||
}
|
||||
|
||||
clickNew() {
|
||||
this.createNew.next(this._lastSearchTerm)
|
||||
this.clearLastSearchTerm()
|
||||
this.ngSelectComponent?.close()
|
||||
}
|
||||
|
||||
clearLastSearchTerm() {
|
||||
|
||||
+111
-60
@@ -559,18 +559,19 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
documents: [3, 4],
|
||||
method: 'modify_tags',
|
||||
parameters: { add_tags: [101], remove_tags: [] },
|
||||
})
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify tags bulk operation for all filtered documents', () => {
|
||||
@@ -607,17 +608,18 @@ describe('BulkEditorComponent', () => {
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
all: true,
|
||||
filters: { title_search: 'apple' },
|
||||
excluded_documents: [4],
|
||||
method: 'modify_tags',
|
||||
parameters: { add_tags: [101], remove_tags: [] },
|
||||
})
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
|
||||
@@ -644,12 +646,12 @@ describe('BulkEditorComponent', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||
.flush(true)
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should set modal dialog text accordingly for tag edit confirmation', () => {
|
||||
@@ -729,18 +731,19 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
documents: [3, 4],
|
||||
method: 'set_correspondent',
|
||||
parameters: { correspondent: 101 },
|
||||
})
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => {
|
||||
@@ -767,12 +770,12 @@ describe('BulkEditorComponent', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||
.flush(true)
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should set modal dialog text accordingly for correspondent edit confirmation', () => {
|
||||
@@ -827,18 +830,19 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
documents: [3, 4],
|
||||
method: 'set_document_type',
|
||||
parameters: { document_type: 101 },
|
||||
})
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify document type bulk operation with confirmation dialog if enabled', () => {
|
||||
@@ -865,12 +869,12 @@ describe('BulkEditorComponent', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||
.flush(true)
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should set modal dialog text accordingly for document type edit confirmation', () => {
|
||||
@@ -925,18 +929,19 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
documents: [3, 4],
|
||||
method: 'set_storage_path',
|
||||
parameters: { storage_path: 101 },
|
||||
})
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => {
|
||||
@@ -963,12 +968,12 @@ describe('BulkEditorComponent', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||
.flush(true)
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should set modal dialog text accordingly for storage path edit confirmation', () => {
|
||||
@@ -1023,18 +1028,19 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
documents: [3, 4],
|
||||
method: 'modify_custom_fields',
|
||||
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
||||
})
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
|
||||
@@ -1061,12 +1067,12 @@ describe('BulkEditorComponent', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||
.flush(true)
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
|
||||
// coverage for modal messages
|
||||
component.setCustomFields({
|
||||
@@ -1540,6 +1546,47 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'shows queued feedback and gates the task link on permission (%s)',
|
||||
(canViewTasks) => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockReturnValue(canViewTasks)
|
||||
const queued = jest.spyOn(toastService, 'show')
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((instances) => {
|
||||
if (instances.length) modal = instances[0]
|
||||
})
|
||||
const reload = jest.spyOn(documentListViewService, 'reload')
|
||||
component.setPermissions()
|
||||
const close = jest.spyOn(modal, 'close')
|
||||
modal.componentInstance.confirmClicked.emit({
|
||||
permissions: {},
|
||||
merge: true,
|
||||
})
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'queued-operation' })
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
const notification = queued.mock.calls.at(-1)[0]
|
||||
expect(notification.content).toBe('Bulk operation queued.')
|
||||
if (canViewTasks) {
|
||||
expect(notification.actionName).toBe('View task')
|
||||
notification.action()
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/tasks'], {
|
||||
queryParams: { task_id: 'queued-operation' },
|
||||
})
|
||||
} else {
|
||||
expect(notification.action).toBeUndefined()
|
||||
expect(notification.actionName).toBeUndefined()
|
||||
}
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(documentListViewService.selected.size).toBe(0)
|
||||
expect(documentListViewService.allSelected).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('should support bulk permissions update', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
@@ -1572,8 +1619,9 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'queued-task' })
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
documents: [3, 4],
|
||||
method: 'set_permissions',
|
||||
parameters: {
|
||||
@@ -1581,12 +1629,12 @@ describe('BulkEditorComponent', () => {
|
||||
merge: true,
|
||||
},
|
||||
})
|
||||
httpTestingController.match(
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||
@@ -1887,15 +1935,18 @@ describe('BulkEditorComponent', () => {
|
||||
expect(toastServiceShowErrorSpy).toHaveBeenCalled()
|
||||
expect(listReloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
modal.componentInstance.succeeded.emit()
|
||||
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
||||
expect(listReloadSpy).toHaveBeenCalled()
|
||||
httpTestingController.match(
|
||||
modal.componentInstance.succeeded.emit({
|
||||
result: 'OK',
|
||||
task_id: 'custom-fields-task',
|
||||
})
|
||||
expect(toastServiceShowInfoSpy).not.toHaveBeenCalled()
|
||||
expect(listReloadSpy).not.toHaveBeenCalled()
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
)
|
||||
})
|
||||
|
||||
it('should create share link bundle and enable manage callback', () => {
|
||||
|
||||
@@ -104,11 +104,11 @@ export class BulkEditorComponent
|
||||
private correspondentService = inject(CorrespondentService)
|
||||
list = inject(DocumentListViewService)
|
||||
private documentService = inject(DocumentService)
|
||||
private trashService = inject(TrashService)
|
||||
private modalService = inject(NgbModal)
|
||||
private openDocumentService = inject(OpenDocumentsService)
|
||||
private settings = inject(SettingsService)
|
||||
private toastService = inject(ToastService)
|
||||
private trashService = inject(TrashService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
public readonly permissionService = inject(PermissionsService)
|
||||
@@ -299,11 +299,33 @@ export class BulkEditorComponent
|
||||
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => this.handleOperationSuccess(modal),
|
||||
next: ({ task_id }) => this.handleOperationQueued(modal, task_id),
|
||||
error: (error) => this.handleOperationError(modal, error),
|
||||
})
|
||||
}
|
||||
|
||||
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
|
||||
modal?.close()
|
||||
this.list.selectNone()
|
||||
const canViewTasks = this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.PaperlessTask
|
||||
)
|
||||
this.toastService.show({
|
||||
content: $localize`Bulk operation queued.`,
|
||||
delay: 5000,
|
||||
...(canViewTasks
|
||||
? {
|
||||
actionName: $localize`View task`,
|
||||
action: () =>
|
||||
this.router.navigate(['/tasks'], {
|
||||
queryParams: { task_id: taskId },
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
private executeDocumentAction(
|
||||
modal: NgbModalRef,
|
||||
request: Observable<any>,
|
||||
@@ -1088,14 +1110,9 @@ export class BulkEditorComponent
|
||||
|
||||
dialog.selection = this.getSelectionQuery()
|
||||
dialog.selectionCount = this.getSelectionSize()
|
||||
dialog.succeeded.subscribe((result) => {
|
||||
this.toastService.showSuccess($localize`Custom fields updated.`)
|
||||
this.list.reload()
|
||||
this.list.reduceSelectionToFilter()
|
||||
this.list.selected.forEach((id) => {
|
||||
this.openDocumentService.refreshDocument(id)
|
||||
})
|
||||
})
|
||||
dialog.succeeded.subscribe(({ task_id }) =>
|
||||
this.handleOperationQueued(null, task_id)
|
||||
)
|
||||
dialog.failed.subscribe((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error updating custom fields.`,
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
|
||||
it('should emit succeeded event and close modal on successful save', () => {
|
||||
const editSpy = jest
|
||||
.spyOn(documentService, 'bulkEdit')
|
||||
.mockReturnValue(of('Success'))
|
||||
.mockReturnValue(of({ result: 'OK', task_id: 'custom-fields-task' }))
|
||||
const successSpy = jest.spyOn(component.succeeded, 'emit')
|
||||
|
||||
component.selection = [1, 2]
|
||||
|
||||
+7
-3
@@ -16,6 +16,7 @@ import { NumberComponent } from 'src/app/components/common/input/number/number.c
|
||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
||||
import { BulkOperationResponse } from 'src/app/data/bulk-operation'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import {
|
||||
DocumentSelectionQuery,
|
||||
@@ -49,7 +50,7 @@ export class CustomFieldsBulkEditDialogComponent {
|
||||
CustomFieldDataType = CustomFieldDataType
|
||||
|
||||
@Output()
|
||||
succeeded = new EventEmitter()
|
||||
succeeded = new EventEmitter<BulkOperationResponse>()
|
||||
|
||||
@Output()
|
||||
failed = new EventEmitter()
|
||||
@@ -103,6 +104,8 @@ export class CustomFieldsBulkEditDialogComponent {
|
||||
}
|
||||
|
||||
public save() {
|
||||
if (this.networkActive) return
|
||||
this.networkActive = true
|
||||
this.documentService
|
||||
.bulkEdit(this.selection, 'modify_custom_fields', {
|
||||
add_custom_fields: this.form.value,
|
||||
@@ -110,11 +113,12 @@ export class CustomFieldsBulkEditDialogComponent {
|
||||
})
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
next: (response) => {
|
||||
this.activeModal.close()
|
||||
this.succeeded.emit()
|
||||
this.succeeded.emit(response)
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.failed.emit(error)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PaperlessTaskStatus } from './paperless-task'
|
||||
|
||||
export interface BulkOperationResponse {
|
||||
result: string
|
||||
task_id: string
|
||||
}
|
||||
|
||||
export interface BulkOperationStatus {
|
||||
task_id: string
|
||||
operation: string
|
||||
status: PaperlessTaskStatus
|
||||
error?: string
|
||||
}
|
||||
@@ -124,6 +124,42 @@ describe('DocumentListViewService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves the current route on a background refresh (saved view: %s)',
|
||||
(savedView) => {
|
||||
if (savedView) documentListViewService.activateSavedView(view)
|
||||
const navigate = jest.mocked(router.navigate)
|
||||
navigate.mockClear()
|
||||
documentListViewService.reload(null, false)
|
||||
httpTestingController
|
||||
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
|
||||
.flush(full_results)
|
||||
expect(documentListViewService.documents).toEqual(full_results.results)
|
||||
expect(navigate).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the route when a background refresh falls back to the first page', () => {
|
||||
jest
|
||||
.spyOn(documentListViewService, 'saveDocumentListView')
|
||||
.mockImplementation(() => {})
|
||||
documentListViewService.currentPage = 2
|
||||
httpTestingController
|
||||
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
|
||||
.flush(full_results)
|
||||
const navigate = jest.mocked(router.navigate)
|
||||
navigate.mockClear()
|
||||
documentListViewService.reload(null, false)
|
||||
httpTestingController
|
||||
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
|
||||
.flush('Not found', { status: 404, statusText: 'Not found' })
|
||||
httpTestingController
|
||||
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
|
||||
.flush(full_results)
|
||||
expect(documentListViewService.currentPage).toBe(1)
|
||||
expect(navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reload the list', () => {
|
||||
expect(documentListViewService.currentPage).toEqual(1)
|
||||
documentListViewService.reload()
|
||||
|
||||
@@ -380,7 +380,7 @@ export class DocumentListViewService {
|
||||
queryParams: paramsFromViewState(activeListViewState),
|
||||
replaceUrl: !this.router.routerState.snapshot.url.includes('?'), // in case navigating from params-less /documents
|
||||
})
|
||||
} else if (this._activeSavedViewId) {
|
||||
} else if (updateQueryParams && this._activeSavedViewId) {
|
||||
this.router.navigate([], {
|
||||
queryParams: paramsFromViewState(activeListViewState, true),
|
||||
queryParamsHandling: 'merge',
|
||||
@@ -399,7 +399,7 @@ export class DocumentListViewService {
|
||||
// this happens when applying a filter: the current page might not be available anymore due to the reduced result set.
|
||||
activeListViewState.currentPage = 1
|
||||
this.markChanged()
|
||||
this.reload()
|
||||
this.reload(onFinish, updateQueryParams)
|
||||
} else if (
|
||||
activeListViewState.sortField.indexOf('custom_field') === 0 &&
|
||||
!this.settings
|
||||
|
||||
@@ -248,6 +248,7 @@ describe(`DocumentService`, () => {
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
documents: ids,
|
||||
method,
|
||||
parameters,
|
||||
@@ -271,6 +272,7 @@ describe(`DocumentService`, () => {
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
from_webui: true,
|
||||
all: true,
|
||||
filters: { title__icontains: 'apple' },
|
||||
excluded_documents: [2, 3],
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
import { FilterRule } from 'src/app/data/filter-rule'
|
||||
import { Results, SelectionData } from 'src/app/data/results'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { BulkOperationResponse } from '../../data/bulk-operation'
|
||||
import { queryParamsFromFilterRules } from '../../utils/query-params'
|
||||
import {
|
||||
PermissionAction,
|
||||
@@ -337,11 +338,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
method: DocumentBulkEditMethod,
|
||||
args: any
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
|
||||
...selection,
|
||||
method: method,
|
||||
parameters: args,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'bulk_edit'),
|
||||
{
|
||||
...selection,
|
||||
method: method,
|
||||
from_webui: true,
|
||||
parameters: args,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
deleteDocuments(selection: DocumentSelectionQuery) {
|
||||
|
||||
@@ -438,6 +438,20 @@ describe('ConsumerStatusService', () => {
|
||||
expect(updated).toBeTruthy()
|
||||
})
|
||||
|
||||
it('forwards bulk operation completion events', () => {
|
||||
const received = jest.fn()
|
||||
websocketStatusService.onBulkOperationStatus().subscribe(received)
|
||||
const data = {
|
||||
task_id: 'bulk-task',
|
||||
operation: 'set_permissions',
|
||||
status: 'success',
|
||||
}
|
||||
websocketStatusService.connect()
|
||||
server.send({ type: WebsocketStatusType.BULK_OPERATION_STATUS, data })
|
||||
websocketStatusService.disconnect()
|
||||
expect(received).toHaveBeenCalledWith(data)
|
||||
})
|
||||
|
||||
it('should ignore keep-alive heartbeat messages from the server', () => {
|
||||
let updated = false
|
||||
let deleted = false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject, signal } from '@angular/core'
|
||||
import { Subject } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { BulkOperationStatus } from '../data/bulk-operation'
|
||||
import { User } from '../data/user'
|
||||
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
||||
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
||||
@@ -12,6 +13,7 @@ export enum WebsocketStatusType {
|
||||
DOCUMENTS_DELETED = 'documents_deleted',
|
||||
DOCUMENT_UPDATED = 'document_updated',
|
||||
HEARTBEAT = 'heartbeat',
|
||||
BULK_OPERATION_STATUS = 'bulk_operation_status',
|
||||
}
|
||||
|
||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||
@@ -116,6 +118,13 @@ export class WebsocketStatusService {
|
||||
private readonly documentDeletedSubject = new Subject<boolean>()
|
||||
private readonly documentUpdatedSubject =
|
||||
new Subject<WebsocketDocumentUpdatedMessage>()
|
||||
private readonly bulkOperationStatusSubject =
|
||||
new Subject<BulkOperationStatus>()
|
||||
|
||||
onBulkOperationStatus() {
|
||||
return this.bulkOperationStatusSubject.asObservable()
|
||||
}
|
||||
|
||||
private readonly connectionStatusSubject = new Subject<boolean>()
|
||||
|
||||
private get(taskId: string, filename?: string) {
|
||||
@@ -192,6 +201,7 @@ export class WebsocketStatusService {
|
||||
| WebsocketProgressMessage
|
||||
| WebsocketDocumentsDeletedMessage
|
||||
| WebsocketDocumentUpdatedMessage
|
||||
| BulkOperationStatus
|
||||
} = JSON.parse(ev.data)
|
||||
|
||||
switch (type) {
|
||||
@@ -209,6 +219,12 @@ export class WebsocketStatusService {
|
||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.BULK_OPERATION_STATUS:
|
||||
this.bulkOperationStatusSubject.next(
|
||||
messageData as BulkOperationStatus
|
||||
)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.HEARTBEAT:
|
||||
// keep-alive from the server, see paperless.consumers.StatusConsumer
|
||||
break
|
||||
|
||||
+53
-36
@@ -48,6 +48,7 @@ from documents.versioning import get_root_document
|
||||
from documents.versioning import versions_newest_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Mapping
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
@@ -131,9 +132,24 @@ def _scratch_path(name: str) -> Path:
|
||||
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
|
||||
|
||||
|
||||
def _finish_metadata_edit(
|
||||
document_ids: list[int],
|
||||
update_callback: Callable[[list[int]], None] | None,
|
||||
) -> None:
|
||||
if update_callback is not None:
|
||||
update_callback(document_ids)
|
||||
else:
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": document_ids},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
|
||||
|
||||
def set_correspondent(
|
||||
doc_ids: list[int],
|
||||
correspondent: Correspondent,
|
||||
*,
|
||||
update_callback: Callable[[list[int]], None] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
if correspondent:
|
||||
correspondent = Correspondent.objects.only("pk").get(id=correspondent)
|
||||
@@ -146,15 +162,17 @@ def set_correspondent(
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
qs.update(correspondent=correspondent)
|
||||
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
_finish_metadata_edit(affected_docs, update_callback)
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
def set_storage_path(doc_ids: list[int], storage_path: StoragePath) -> Literal["OK"]:
|
||||
def set_storage_path(
|
||||
doc_ids: list[int],
|
||||
storage_path: StoragePath,
|
||||
*,
|
||||
update_callback: Callable[[list[int]], None] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
if storage_path:
|
||||
storage_path = StoragePath.objects.only("pk").get(id=storage_path)
|
||||
|
||||
@@ -168,15 +186,17 @@ def set_storage_path(doc_ids: list[int], storage_path: StoragePath) -> Literal["
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
qs.update(storage_path=storage_path)
|
||||
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
_finish_metadata_edit(affected_docs, update_callback)
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Literal["OK"]:
|
||||
def set_document_type(
|
||||
doc_ids: list[int],
|
||||
document_type: DocumentType,
|
||||
*,
|
||||
update_callback: Callable[[list[int]], None] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
if document_type:
|
||||
document_type = DocumentType.objects.only("pk").get(id=document_type)
|
||||
|
||||
@@ -188,15 +208,17 @@ def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Litera
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
qs.update(document_type=document_type)
|
||||
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
_finish_metadata_edit(affected_docs, update_callback)
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
||||
def add_tag(
|
||||
doc_ids: list[int],
|
||||
tag: int,
|
||||
*,
|
||||
update_callback: Callable[[list[int]], None] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
tag_obj = Tag.objects.get(pk=tag)
|
||||
tags_to_add = [tag_obj, *tag_obj.get_ancestors()]
|
||||
|
||||
@@ -217,15 +239,17 @@ def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
||||
DocumentTagRelationship.objects.bulk_create(to_create)
|
||||
|
||||
if affected_docs:
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": list(affected_docs)},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
_finish_metadata_edit(list(affected_docs), update_callback)
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
||||
def remove_tag(
|
||||
doc_ids: list[int],
|
||||
tag: int,
|
||||
*,
|
||||
update_callback: Callable[[list[int]], None] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
tag_obj = Tag.objects.get(pk=tag)
|
||||
tag_ids = [tag_obj.id, *tag_obj.get_descendants_pks()]
|
||||
|
||||
@@ -238,10 +262,7 @@ def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
||||
qs.delete()
|
||||
|
||||
if affected_docs:
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
_finish_metadata_edit(affected_docs, update_callback)
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -250,6 +271,8 @@ def modify_tags(
|
||||
doc_ids: list[int],
|
||||
add_tags: list[int],
|
||||
remove_tags: list[int],
|
||||
*,
|
||||
update_callback: Callable[[list[int]], None] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
@@ -293,10 +316,7 @@ def modify_tags(
|
||||
)
|
||||
|
||||
if affected_docs:
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
_finish_metadata_edit(affected_docs, update_callback)
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -305,6 +325,8 @@ def modify_custom_fields(
|
||||
doc_ids: list[int],
|
||||
add_custom_fields: list[int] | dict,
|
||||
remove_custom_fields: list[int],
|
||||
*,
|
||||
update_callback: Callable[[list[int]], None] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
@@ -367,10 +389,7 @@ def modify_custom_fields(
|
||||
field_id__in=remove_custom_fields,
|
||||
).hard_delete()
|
||||
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
_finish_metadata_edit(affected_docs, update_callback)
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -451,6 +470,7 @@ def set_permissions(
|
||||
*,
|
||||
owner: User | None = None,
|
||||
merge: bool = False,
|
||||
update_callback: Callable[[list[int]], None] | None = None,
|
||||
) -> Literal["OK"]:
|
||||
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
|
||||
|
||||
@@ -468,10 +488,7 @@ def set_permissions(
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
||||
)
|
||||
_finish_metadata_edit(affected_docs, update_callback)
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Queued document metadata edits."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auditlog.context import set_actor
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import transaction
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from documents import bulk_edit
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.permissions import has_document_operation_permissions
|
||||
from documents.tasks import bulk_update_documents
|
||||
|
||||
MODIFIED_FIELDS = {
|
||||
"set_correspondent": "correspondent",
|
||||
"set_document_type": "document_type",
|
||||
"set_storage_path": "storage_path",
|
||||
"add_tag": "tags",
|
||||
"remove_tag": "tags",
|
||||
"modify_tags": "tags",
|
||||
"modify_custom_fields": "custom_fields",
|
||||
"set_permissions": None,
|
||||
}
|
||||
QUEUED_OPERATIONS = frozenset(MODIFIED_FIELDS)
|
||||
|
||||
|
||||
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
||||
"""
|
||||
Returns each document's current value of field, for the audit log.
|
||||
|
||||
Tags and custom fields are one row per value, so they are gathered
|
||||
into a sorted list of pks per document (empty when there are none).
|
||||
Reading them through Document.values() instead would join those rows
|
||||
and return one arbitrary value per document.
|
||||
"""
|
||||
if field == "tags":
|
||||
rows = (
|
||||
Document.tags.through.objects.filter(document_id__in=doc_ids)
|
||||
.order_by("tag_id")
|
||||
.values_list("document_id", "tag_id")
|
||||
)
|
||||
elif field == "custom_fields":
|
||||
rows = (
|
||||
CustomFieldInstance.objects.filter(document_id__in=doc_ids)
|
||||
.order_by("pk")
|
||||
.values_list("document_id", "pk")
|
||||
)
|
||||
else:
|
||||
return dict(
|
||||
Document.objects.filter(pk__in=doc_ids).values_list("pk", field),
|
||||
)
|
||||
|
||||
values: dict[int, list[int]] = {doc_id: [] for doc_id in doc_ids}
|
||||
for doc_id, pk in rows:
|
||||
values[doc_id].append(pk)
|
||||
return values
|
||||
|
||||
|
||||
@shared_task
|
||||
def execute_bulk_operation(
|
||||
operation: str,
|
||||
document_ids: list[int],
|
||||
parameters: dict,
|
||||
user_id: int,
|
||||
) -> dict:
|
||||
if operation not in QUEUED_OPERATIONS:
|
||||
raise ValueError(f"Unsupported bulk operation: {operation}")
|
||||
method = getattr(bulk_edit, operation)
|
||||
user = User.objects.get(pk=user_id)
|
||||
if not has_document_operation_permissions(
|
||||
user=user,
|
||||
documents=document_ids,
|
||||
method=method,
|
||||
parameters=parameters,
|
||||
):
|
||||
raise PermissionDenied("Insufficient permissions to execute bulk operation")
|
||||
|
||||
affected_ids: list[int] = []
|
||||
modified_field = MODIFIED_FIELDS[operation]
|
||||
# Keep metadata writes and their audit entries together. Follow-up processing
|
||||
# runs after commit, within this same tracked task.
|
||||
with set_actor(user), transaction.atomic():
|
||||
old_values = (
|
||||
snapshot_field(document_ids, modified_field)
|
||||
if settings.AUDIT_LOG_ENABLED and modified_field
|
||||
else {}
|
||||
)
|
||||
method(document_ids, **parameters, update_callback=affected_ids.extend)
|
||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
new_values = snapshot_field(document_ids, modified_field)
|
||||
for doc in Document.objects.filter(pk__in=document_ids):
|
||||
LogEntry.objects.log_create(
|
||||
instance=doc,
|
||||
changes={modified_field: [old_values[doc.pk], new_values[doc.pk]]},
|
||||
action=LogEntry.Action.UPDATE,
|
||||
actor=user,
|
||||
additional_data={"reason": f"Bulk edit: {operation}"},
|
||||
)
|
||||
if affected_ids:
|
||||
bulk_update_documents(list(dict.fromkeys(affected_ids)))
|
||||
return {"operation": operation, "document_count": len(document_ids)}
|
||||
+10
-19
@@ -15,6 +15,7 @@ from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from documents.barcodes import read_barcode_values
|
||||
@@ -26,10 +27,7 @@ from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.file_handling import validate_path_in_root
|
||||
from documents.file_moves import defer_file_moves
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
from documents.loggers import LoggingMixin
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -607,13 +605,7 @@ class ConsumerPlugin(
|
||||
# now that everything is done, we can start to store the document
|
||||
# in the system. This will be a transaction and reasonably fast.
|
||||
try:
|
||||
# File moves requested in here run after commit and after
|
||||
# media.lock is released. media.lock is taken before the
|
||||
# transaction: the version path locks the root row with
|
||||
# select_for_update, and workflow email actions and file
|
||||
# placement below re-enter media.lock instead of waiting
|
||||
# on it while rows are locked.
|
||||
with defer_file_moves(), media_lock(), transaction.atomic():
|
||||
with transaction.atomic():
|
||||
# store the document.
|
||||
if self.input_doc.root_document_id:
|
||||
# If this is a new version of an existing document, we need
|
||||
@@ -703,7 +695,7 @@ class ConsumerPlugin(
|
||||
|
||||
# After everything is in the database, copy the files into
|
||||
# place. If this fails, we'll also rollback the transaction.
|
||||
with media_lock():
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
generated_filename = generate_unique_filename(document)
|
||||
if (
|
||||
len(str(generated_filename))
|
||||
@@ -767,9 +759,10 @@ class ConsumerPlugin(
|
||||
document.archive_path,
|
||||
)
|
||||
|
||||
# Any filename follow-up from this save is deferred by
|
||||
# the scope above until after commit.
|
||||
document.save(write_file_paths=True)
|
||||
# Don't save with the lock active. Saving will cause the file
|
||||
# renaming logic to acquire the lock as well.
|
||||
# This triggers things like file renaming
|
||||
document.save()
|
||||
|
||||
if document.root_document_id:
|
||||
document_updated.send(
|
||||
@@ -810,11 +803,6 @@ class ConsumerPlugin(
|
||||
exception=e,
|
||||
)
|
||||
|
||||
# The storage scope above nests under the consume task's scope, so its
|
||||
# moves are still pending; place the file before the script reads its path.
|
||||
flush_pending_file_moves()
|
||||
document.refresh_from_db()
|
||||
|
||||
self.run_post_consume_script(document)
|
||||
|
||||
self.log.info(f"Document {document} consumption finished")
|
||||
@@ -827,6 +815,9 @@ class ConsumerPlugin(
|
||||
document.id,
|
||||
)
|
||||
|
||||
# Return the most up to date fields
|
||||
document.refresh_from_db()
|
||||
|
||||
return ConsumeFileSuccessResult(document_id=document.pk)
|
||||
|
||||
def _parse_title_placeholders(self, title: str) -> str:
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
"""
|
||||
Document file moves, coalesced to once per unit of work.
|
||||
|
||||
Every save that can change a document's generated filename asks for a file
|
||||
move through request_file_move(). Inside a scope opened by defer_file_moves()
|
||||
(a request, a Celery task, the consumer, or a per-item command step) the
|
||||
request is only recorded, and the outermost scope moves each recorded document
|
||||
once, after its own writes have committed. Outside any scope the move happens
|
||||
immediately.
|
||||
|
||||
The mover always takes media.lock first and then reads the document fresh, so
|
||||
it starts from the latest committed state and never waits on media.lock while
|
||||
its own transaction holds document rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Final
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from filelock import Timeout
|
||||
|
||||
from documents.caching import clear_document_caches
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Document
|
||||
from documents.utils import compute_checksum
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import Iterator
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
|
||||
MEDIA_LOCK_TIMEOUT_SECONDS: Final[int] = 300
|
||||
|
||||
|
||||
class CannotMoveFilesException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def in_user_transaction() -> bool:
|
||||
"""
|
||||
True when the default connection is inside an atomic block that was not
|
||||
created by Django's test case machinery.
|
||||
|
||||
Django marks the atomic blocks TestCase (and pytest-django) wrap around
|
||||
each test with _from_testcase. Ignoring them keeps tests on the same path
|
||||
as production.
|
||||
"""
|
||||
connection = transaction.get_connection()
|
||||
return any(not block._from_testcase for block in connection.atomic_blocks)
|
||||
|
||||
|
||||
class FileMoveCollector:
|
||||
"""Pending document pks for one file move scope."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: set[int] = set()
|
||||
self._lock = threading.Lock()
|
||||
self.closed = False
|
||||
|
||||
def add(self, pk: int) -> bool:
|
||||
"""Record a pending move. Returns False if the collector is closed."""
|
||||
with self._lock:
|
||||
if self.closed:
|
||||
return False
|
||||
self._pending.add(pk)
|
||||
return True
|
||||
|
||||
def take_pending(self) -> list[int]:
|
||||
"""Remove and return the pending pks, sorted, leaving the collector open."""
|
||||
with self._lock:
|
||||
pending = sorted(self._pending)
|
||||
self._pending.clear()
|
||||
return pending
|
||||
|
||||
def close(self) -> list[int]:
|
||||
"""Close the collector and return what was still pending, sorted."""
|
||||
with self._lock:
|
||||
self.closed = True
|
||||
pending = sorted(self._pending)
|
||||
self._pending.clear()
|
||||
return pending
|
||||
|
||||
|
||||
# Only the scope context managers call set() / reset(). Everything else reads
|
||||
# it and mutates the collector, so adds made in a copied context (thread or
|
||||
# task hops) are visible to the scope that flushes.
|
||||
_active_collector: ContextVar[FileMoveCollector | None] = ContextVar(
|
||||
"paperless_file_move_collector",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
|
||||
if checksum is None or not path.is_file():
|
||||
return False
|
||||
|
||||
return compute_checksum(path) == checksum
|
||||
|
||||
|
||||
def move_files_for_document(pk: int) -> Document | None:
|
||||
"""
|
||||
Move the document's original and archive to the paths its current
|
||||
committed state generates, then do the same for its versions.
|
||||
|
||||
Returns the fresh instance with its final filename columns, or None when
|
||||
the document does not exist, has no filename yet, or the lock timed out.
|
||||
"""
|
||||
# Cheap check without the lock: most saves during consumption happen
|
||||
# before the file is placed. filename is never cleared once set, so this
|
||||
# stays true under the lock. values_list() keeps the check off
|
||||
# Document.global_objects.filter(), which tests patch to inject database
|
||||
# errors into and observe the mover's own writes.
|
||||
filename = (
|
||||
Document.global_objects.values_list("filename", flat=True).filter(pk=pk).first()
|
||||
)
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
try:
|
||||
with media_lock(timeout=MEDIA_LOCK_TIMEOUT_SECONDS):
|
||||
try:
|
||||
document = Document.global_objects.get(pk=pk)
|
||||
except Document.DoesNotExist:
|
||||
logger.debug(f"Document {pk} no longer exists, skipping file move")
|
||||
return None
|
||||
_move_document_files(document)
|
||||
except Timeout:
|
||||
logger.error(
|
||||
f"Timed out after {MEDIA_LOCK_TIMEOUT_SECONDS}s waiting for "
|
||||
f"{settings.MEDIA_LOCK} to move files of document {pk}; the file "
|
||||
f"keeps its current name and will be renamed on its next save",
|
||||
)
|
||||
return None
|
||||
|
||||
# Keep version files in sync with root
|
||||
if document.root_document_id is None:
|
||||
version_pks = list(
|
||||
Document.global_objects.values_list("pk", flat=True).filter(
|
||||
root_document_id=pk,
|
||||
),
|
||||
)
|
||||
for version_pk in version_pks:
|
||||
move_files_for_document(version_pk)
|
||||
|
||||
return document
|
||||
|
||||
|
||||
def _flush(pks: list[int]) -> None:
|
||||
"""Move each document; one failure never stops the rest or raises."""
|
||||
for pk in pks:
|
||||
try:
|
||||
move_files_for_document(pk)
|
||||
except Exception:
|
||||
logger.exception(f"Unexpected error moving files of document {pk}")
|
||||
|
||||
|
||||
def _flush_after_commit(pks: list[int]) -> None:
|
||||
if not pks:
|
||||
return
|
||||
if in_user_transaction():
|
||||
transaction.on_commit(lambda: _flush(pks))
|
||||
else:
|
||||
_flush(pks)
|
||||
|
||||
|
||||
def _close_and_flush(collector: FileMoveCollector) -> None:
|
||||
_flush_after_commit(collector.close())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def defer_file_moves() -> Iterator[None]:
|
||||
"""
|
||||
Collect file move requests and perform each once when the outermost scope
|
||||
exits, after its own writes have committed.
|
||||
"""
|
||||
current = _active_collector.get()
|
||||
if current is not None and not current.closed:
|
||||
yield
|
||||
return
|
||||
|
||||
collector = FileMoveCollector()
|
||||
token = _active_collector.set(collector)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_active_collector.reset(token)
|
||||
_close_and_flush(collector)
|
||||
|
||||
|
||||
def flush_pending_file_moves() -> None:
|
||||
"""
|
||||
Perform the current scope's pending moves now, keeping the scope open.
|
||||
|
||||
From an eager task inside a request this flushes the request's collector
|
||||
early, which is harmless: moves still run after commit, on fresh state.
|
||||
"""
|
||||
collector = _active_collector.get()
|
||||
if collector is None:
|
||||
return
|
||||
_flush_after_commit(collector.take_pending())
|
||||
|
||||
|
||||
def _validate_move(
|
||||
document: Document,
|
||||
old_path: Path,
|
||||
new_path: Path,
|
||||
root: Path,
|
||||
) -> None:
|
||||
if not new_path.is_relative_to(root):
|
||||
msg = (
|
||||
f"Document {document!s}: Refusing to move file outside root {root}: "
|
||||
f"{new_path}."
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if not old_path.is_file():
|
||||
# Can't do anything if the old file does not exist anymore.
|
||||
msg = f"Document {document!s}: File {old_path} doesn't exist."
|
||||
logger.fatal(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if new_path.is_file():
|
||||
# Can't do anything if the new file already exists. Skip updating file.
|
||||
msg = f"Document {document!s}: Cannot rename file since target path {new_path} already exists."
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
|
||||
def _move_document_files(document: Document) -> None:
|
||||
try:
|
||||
old_filename = document.filename
|
||||
old_source_path = document.source_path
|
||||
move_original = False
|
||||
original_already_moved = False
|
||||
|
||||
old_archive_filename = document.archive_filename
|
||||
old_archive_path = document.archive_path
|
||||
move_archive = False
|
||||
archive_already_moved = False
|
||||
|
||||
candidate_filename = generate_filename(document)
|
||||
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {document!s}: Generated filename exceeds db path "
|
||||
f"limit ({len(str(candidate_filename))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
candidate_source_path = (settings.ORIGINALS_DIR / candidate_filename).resolve()
|
||||
if candidate_filename == Path(old_filename):
|
||||
new_filename = Path(old_filename)
|
||||
elif (
|
||||
candidate_source_path.exists() and candidate_source_path != old_source_path
|
||||
):
|
||||
if not old_source_path.is_file() and _path_matches_checksum(
|
||||
candidate_source_path,
|
||||
document.checksum,
|
||||
):
|
||||
new_filename = candidate_filename
|
||||
original_already_moved = True
|
||||
else:
|
||||
# Only fall back to unique search when there is an actual conflict
|
||||
new_filename = generate_unique_filename(document)
|
||||
else:
|
||||
new_filename = candidate_filename
|
||||
|
||||
# Need to convert to string to be able to save it to the db
|
||||
document.filename = str(new_filename)
|
||||
move_original = old_filename != document.filename and not original_already_moved
|
||||
|
||||
if document.has_archive_version:
|
||||
archive_candidate = generate_filename(document, archive_filename=True)
|
||||
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {document!s}: Generated archive filename exceeds "
|
||||
f"db path limit ({len(str(archive_candidate))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
archive_candidate_path = (
|
||||
settings.ARCHIVE_DIR / archive_candidate
|
||||
).resolve()
|
||||
if archive_candidate == Path(old_archive_filename):
|
||||
new_archive_filename = Path(old_archive_filename)
|
||||
elif (
|
||||
archive_candidate_path.exists()
|
||||
and archive_candidate_path != old_archive_path
|
||||
):
|
||||
if not old_archive_path.is_file() and _path_matches_checksum(
|
||||
archive_candidate_path,
|
||||
document.archive_checksum,
|
||||
):
|
||||
new_archive_filename = archive_candidate
|
||||
archive_already_moved = True
|
||||
else:
|
||||
new_archive_filename = generate_unique_filename(
|
||||
document,
|
||||
archive_filename=True,
|
||||
)
|
||||
else:
|
||||
new_archive_filename = archive_candidate
|
||||
|
||||
document.archive_filename = str(new_archive_filename)
|
||||
|
||||
move_archive = (
|
||||
old_archive_filename != document.archive_filename
|
||||
and not archive_already_moved
|
||||
)
|
||||
else:
|
||||
move_archive = False
|
||||
|
||||
if not move_original and not move_archive:
|
||||
updates: dict[str, object] = {}
|
||||
if old_filename != document.filename:
|
||||
updates["filename"] = document.filename
|
||||
if old_archive_filename != document.archive_filename:
|
||||
updates["archive_filename"] = document.archive_filename
|
||||
if updates:
|
||||
# global_objects so a trashed document's path is fixed too.
|
||||
updates["modified"] = timezone.now()
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.global_objects.filter(pk=document.pk).update(**updates)
|
||||
return
|
||||
|
||||
if move_original:
|
||||
_validate_move(
|
||||
document,
|
||||
old_source_path,
|
||||
document.source_path,
|
||||
settings.ORIGINALS_DIR,
|
||||
)
|
||||
create_source_path_directory(document.source_path)
|
||||
shutil.move(old_source_path, document.source_path)
|
||||
|
||||
if move_archive:
|
||||
_validate_move(
|
||||
document,
|
||||
old_archive_path,
|
||||
document.archive_path,
|
||||
settings.ARCHIVE_DIR,
|
||||
)
|
||||
create_source_path_directory(document.archive_path)
|
||||
shutil.move(old_archive_path, document.archive_path)
|
||||
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.global_objects.filter(pk=document.pk).update(
|
||||
filename=document.filename,
|
||||
archive_filename=document.archive_filename,
|
||||
modified=timezone.now(),
|
||||
)
|
||||
# Clear any caching for this document. Slightly overkill, but not terrible
|
||||
clear_document_caches(document.pk)
|
||||
|
||||
except (OSError, DatabaseError, CannotMoveFilesException) as e:
|
||||
logger.warning(f"Exception during file handling: {e}")
|
||||
# This happens when either:
|
||||
# - moving the files failed due to file system errors
|
||||
# - saving to the database failed due to database errors
|
||||
# In both cases, we need to revert to the original state.
|
||||
|
||||
# Try to move files to their original location.
|
||||
try:
|
||||
if move_original and document.source_path.is_file():
|
||||
logger.info("Restoring previous original path")
|
||||
shutil.move(document.source_path, old_source_path)
|
||||
|
||||
if move_archive and document.archive_path.is_file():
|
||||
logger.info("Restoring previous archive path")
|
||||
shutil.move(document.archive_path, old_archive_path)
|
||||
|
||||
except Exception:
|
||||
# This is fine, since:
|
||||
# A: if we managed to move source from A to B, we will also
|
||||
# manage to move it from B to A. If not, we have a serious
|
||||
# issue that's going to get caught by the santiy checker.
|
||||
# All files remain in place and will never be overwritten,
|
||||
# so this is not the end of the world.
|
||||
# B: if moving the original file failed, nothing has changed
|
||||
# anyway.
|
||||
pass
|
||||
|
||||
# restore old values on the document
|
||||
document.filename = old_filename
|
||||
document.archive_filename = old_archive_filename
|
||||
|
||||
# finally, remove any empty sub folders. This will do nothing if
|
||||
# something has failed above.
|
||||
if not old_source_path.is_file():
|
||||
delete_empty_directories(
|
||||
Path(old_source_path).parent,
|
||||
root=settings.ORIGINALS_DIR,
|
||||
)
|
||||
|
||||
if document.has_archive_version and not old_archive_path.is_file():
|
||||
delete_empty_directories(
|
||||
Path(old_archive_path).parent,
|
||||
root=settings.ARCHIVE_DIR,
|
||||
)
|
||||
|
||||
|
||||
def request_file_move(document: Document) -> None:
|
||||
"""
|
||||
Ask for the document's files to follow its committed state.
|
||||
|
||||
Inside an open scope the request is recorded and performed when the scope
|
||||
ends. Otherwise the move happens now and only the final filename columns
|
||||
are copied onto the caller's instance, so source_path is current after
|
||||
save() while other in-memory fields are left alone.
|
||||
"""
|
||||
collector = _active_collector.get()
|
||||
if collector is not None and collector.add(document.pk):
|
||||
return
|
||||
|
||||
moved = move_files_for_document(document.pk)
|
||||
if moved is not None:
|
||||
document.filename = moved.filename
|
||||
document.archive_filename = moved.archive_filename
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def adefer_file_moves() -> AsyncIterator[None]:
|
||||
"""
|
||||
Async twin of defer_file_moves().
|
||||
|
||||
When something was collected, the flush runs through sync_to_async: its
|
||||
transaction check needs the thread owning the DB connection, and the
|
||||
blocking media.lock must never run on the event loop.
|
||||
"""
|
||||
current = _active_collector.get()
|
||||
if current is not None and not current.closed:
|
||||
yield
|
||||
return
|
||||
|
||||
collector = FileMoveCollector()
|
||||
token = _active_collector.set(collector)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_active_collector.reset(token)
|
||||
pks = collector.close()
|
||||
if pks:
|
||||
await sync_to_async(_flush_after_commit, thread_sensitive=True)(pks)
|
||||
@@ -4,9 +4,9 @@ from dataclasses import dataclass
|
||||
from email import message_from_bytes
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
|
||||
from documents.media_lock import media_lock
|
||||
from filelock import FileLock
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -48,7 +48,7 @@ def send_email(
|
||||
used_filenames: set[str] = set()
|
||||
|
||||
# Something could be renaming the file concurrently so it can't be attached
|
||||
with media_lock():
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
for attachment in attachments:
|
||||
filename = _get_unique_filename(
|
||||
attachment.friendly_name,
|
||||
|
||||
@@ -18,11 +18,10 @@ from django.core import serializers
|
||||
from django.core.management.base import CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
|
||||
from documents.media_lock import media_lock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
@@ -329,7 +328,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
)
|
||||
|
||||
# Prevent any ongoing changes in the documents while exporting
|
||||
with media_lock(), sink:
|
||||
with FileLock(settings.MEDIA_LOCK), sink:
|
||||
self.dump(sink)
|
||||
|
||||
def dump(self, sink: ExportSink) -> None:
|
||||
|
||||
@@ -29,6 +29,7 @@ from django.db.models import GeneratedField
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.db.models.signals import post_save
|
||||
from filelock import FileLock
|
||||
from guardian.shortcuts import clear_ct_cache
|
||||
|
||||
from documents.export.compression import compress_type_readable
|
||||
@@ -36,7 +37,6 @@ from documents.export.compression import unreadable_method_names
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.management.commands.mixins import CryptMixin
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -662,7 +662,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
else:
|
||||
archive_path = None
|
||||
|
||||
with media_lock():
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
if Path(document.source_path).is_file():
|
||||
raise FileExistsError(document.source_path)
|
||||
|
||||
@@ -696,7 +696,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
f"Share link bundle {bundle.pk} does not have a valid file path.",
|
||||
)
|
||||
|
||||
with media_lock():
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
bundle_target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
copy_file_with_basic_stats(
|
||||
bundle_source_path,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from documents.file_moves import move_files_for_document
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.models import Document
|
||||
|
||||
@@ -10,9 +11,5 @@ class Command(PaperlessCommand):
|
||||
supports_multiprocessing = False
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for document in self.track(
|
||||
# move_files_for_document handles a root's versions itself
|
||||
Document.objects.filter(root_document__isnull=True).only("pk"),
|
||||
description="Renaming...",
|
||||
):
|
||||
move_files_for_document(document.pk)
|
||||
for document in self.track(Document.objects.all(), description="Renaming..."):
|
||||
post_save.send(Document, instance=document, created=False)
|
||||
@@ -9,7 +9,6 @@ from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from documents.classifier import load_classifier
|
||||
from documents.file_moves import defer_file_moves
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.models import Document
|
||||
from documents.signals.handlers import set_correspondent
|
||||
@@ -274,63 +273,61 @@ class Command(PaperlessCommand):
|
||||
description="Retagging...",
|
||||
stats_renderer=render_stats,
|
||||
):
|
||||
# One file move per document, after all of its assignments.
|
||||
with defer_file_moves():
|
||||
suggestion = DocumentSuggestion(document=document)
|
||||
suggestion = DocumentSuggestion(document=document)
|
||||
|
||||
if do_correspondent:
|
||||
correspondent = set_correspondent(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if correspondent is not None:
|
||||
stats.correspondents += 1
|
||||
suggestion.correspondent = correspondent
|
||||
if do_correspondent:
|
||||
correspondent = set_correspondent(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if correspondent is not None:
|
||||
stats.correspondents += 1
|
||||
suggestion.correspondent = correspondent
|
||||
|
||||
if do_document_type:
|
||||
document_type = set_document_type(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if document_type is not None:
|
||||
stats.document_types += 1
|
||||
suggestion.document_type = document_type
|
||||
if do_document_type:
|
||||
document_type = set_document_type(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if document_type is not None:
|
||||
stats.document_types += 1
|
||||
suggestion.document_type = document_type
|
||||
|
||||
if do_tags:
|
||||
tags_to_add, tags_to_remove = set_tags(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
dry_run=suggest,
|
||||
)
|
||||
stats.tags_added += len(tags_to_add)
|
||||
stats.tags_removed += len(tags_to_remove)
|
||||
suggestion.tags_to_add = frozenset(tags_to_add)
|
||||
suggestion.tags_to_remove = frozenset(tags_to_remove)
|
||||
if do_tags:
|
||||
tags_to_add, tags_to_remove = set_tags(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
dry_run=suggest,
|
||||
)
|
||||
stats.tags_added += len(tags_to_add)
|
||||
stats.tags_removed += len(tags_to_remove)
|
||||
suggestion.tags_to_add = frozenset(tags_to_add)
|
||||
suggestion.tags_to_remove = frozenset(tags_to_remove)
|
||||
|
||||
if do_storage_path:
|
||||
storage_path = set_storage_path(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if storage_path is not None:
|
||||
stats.storage_paths += 1
|
||||
suggestion.storage_path = storage_path
|
||||
if do_storage_path:
|
||||
storage_path = set_storage_path(
|
||||
None,
|
||||
document,
|
||||
classifier=classifier,
|
||||
replace=overwrite,
|
||||
use_first=use_first,
|
||||
dry_run=suggest,
|
||||
)
|
||||
if storage_path is not None:
|
||||
stats.storage_paths += 1
|
||||
suggestion.storage_path = storage_path
|
||||
|
||||
stats.documents_processed += 1
|
||||
stats.documents_processed += 1
|
||||
|
||||
if suggest:
|
||||
suggestions.append(suggestion)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""
|
||||
The global media lock, reentrant within a thread.
|
||||
|
||||
Code that both needs media.lock and opens a transaction that locks document
|
||||
rows must take media.lock first, so no transaction ever waits on it while
|
||||
holding rows. Reentrancy lets code already holding the lock (for example the
|
||||
consumer) call into code that takes it again (for example a workflow email
|
||||
action attaching files) without blocking itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from filelock import FileLock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def media_lock(timeout: float = -1) -> Iterator[None]:
|
||||
"""
|
||||
Hold settings.MEDIA_LOCK for the block.
|
||||
|
||||
is_singleton returns one FileLock instance per path in this process, and
|
||||
the default thread_local=True keeps the hold count per thread, so the
|
||||
same thread re-enters while other threads and processes are excluded.
|
||||
The timeout goes to acquire() because a singleton rejects construction
|
||||
with different parameters. A negative timeout waits forever.
|
||||
"""
|
||||
lock = FileLock(settings.MEDIA_LOCK, is_singleton=True)
|
||||
with lock.acquire(timeout=timeout):
|
||||
yield
|
||||
@@ -2,7 +2,6 @@ import datetime
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
import pathvalidate
|
||||
@@ -216,12 +215,6 @@ class StoragePath(MatchingModel):
|
||||
|
||||
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||
# Written only by the file mover, the consumer's file placement and the
|
||||
# archiver. A full save() of a stale instance must never write them, or
|
||||
# the DB can point at a path the file has already been moved away from.
|
||||
FILE_PATH_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{"filename", "archive_filename"},
|
||||
)
|
||||
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
|
||||
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
||||
|
||||
@@ -433,31 +426,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
res += f" {self.title}"
|
||||
return res
|
||||
|
||||
def save(self, *, write_file_paths: bool = False, **kwargs: Any) -> None:
|
||||
"""
|
||||
Save the document without writing filename / archive_filename on an
|
||||
existing row, unless write_file_paths=True or update_fields names them.
|
||||
|
||||
This mirrors how Django narrows a save on a deferred instance: only the
|
||||
loaded, concrete, non-generated, non-primary-key fields are written.
|
||||
"""
|
||||
if (
|
||||
not write_file_paths
|
||||
and not self._state.adding
|
||||
and kwargs.get("update_fields") is None
|
||||
and not kwargs.get("force_insert", False)
|
||||
):
|
||||
deferred = self.get_deferred_fields()
|
||||
kwargs["update_fields"] = [
|
||||
field.attname
|
||||
for field in self._meta.concrete_fields
|
||||
if not field.primary_key
|
||||
and not field.generated
|
||||
and field.name not in self.FILE_PATH_FIELDS
|
||||
and field.attname not in deferred
|
||||
]
|
||||
super().save(**kwargs)
|
||||
|
||||
def get_effective_barcodes(self) -> list["DocumentBarcode"]:
|
||||
"""
|
||||
Returns the stored barcodes for the document, like
|
||||
|
||||
@@ -739,3 +739,100 @@ class AcknowledgeTasksPermissions(GlobalPermissionsByMethod):
|
||||
"OPTIONS": ["documents.change_paperlesstask"],
|
||||
"POST": ["documents.change_paperlesstask"],
|
||||
}
|
||||
|
||||
|
||||
def has_document_operation_permissions(
|
||||
*,
|
||||
user: User,
|
||||
documents: list[int],
|
||||
method,
|
||||
parameters: dict[str, Any],
|
||||
) -> bool:
|
||||
from documents import bulk_edit
|
||||
|
||||
if not user.is_active:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
|
||||
root_docs = {
|
||||
get_root_document(doc)
|
||||
for doc in Document.objects.select_related(
|
||||
"owner",
|
||||
"root_document__owner",
|
||||
).filter(pk__in=documents)
|
||||
}
|
||||
user_is_owner_of_all_documents = all(
|
||||
(doc.owner == user or doc.owner is None) for doc in root_docs
|
||||
)
|
||||
|
||||
# check global and object permissions for all documents
|
||||
has_perms = (
|
||||
user.has_perm(
|
||||
"documents.change_document",
|
||||
)
|
||||
and not Document.global_objects.filter(pk__in=documents)
|
||||
.exclude(pk__in=permitted_document_ids(user, perm="change_document"))
|
||||
.exists()
|
||||
)
|
||||
|
||||
# check ownership for methods that change original document
|
||||
if (
|
||||
(
|
||||
has_perms
|
||||
and method
|
||||
in [
|
||||
bulk_edit.set_permissions,
|
||||
bulk_edit.delete,
|
||||
bulk_edit.rotate,
|
||||
bulk_edit.delete_pages,
|
||||
bulk_edit.edit_pdf,
|
||||
bulk_edit.merge_as_versions,
|
||||
bulk_edit.remove_password,
|
||||
]
|
||||
)
|
||||
or (
|
||||
method in [bulk_edit.merge, bulk_edit.split]
|
||||
and parameters.get("delete_originals")
|
||||
)
|
||||
or (method == bulk_edit.edit_pdf and parameters.get("update_document"))
|
||||
):
|
||||
has_perms = has_perms and user_is_owner_of_all_documents
|
||||
|
||||
# check global add permissions for methods that create documents
|
||||
if (
|
||||
has_perms
|
||||
and (
|
||||
method in [bulk_edit.split, bulk_edit.merge]
|
||||
or (
|
||||
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
||||
and not parameters.get("update_document")
|
||||
)
|
||||
)
|
||||
and not user.has_perm("documents.add_document")
|
||||
):
|
||||
has_perms = False
|
||||
|
||||
# check global delete permissions for methods that delete documents
|
||||
if (
|
||||
has_perms
|
||||
and (
|
||||
method == bulk_edit.delete
|
||||
# Sources stop being documents of their own, and removing one
|
||||
# again afterwards needs delete_document
|
||||
or method == bulk_edit.merge_as_versions
|
||||
or (
|
||||
method in [bulk_edit.merge, bulk_edit.split]
|
||||
and parameters.get("delete_originals")
|
||||
)
|
||||
or (
|
||||
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
||||
and parameters.get("delete_original")
|
||||
and not parameters.get("update_document")
|
||||
)
|
||||
)
|
||||
and not user.has_perm("documents.delete_document")
|
||||
):
|
||||
has_perms = False
|
||||
|
||||
return has_perms
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from celery import shared_task
|
||||
from celery.signals import before_task_publish
|
||||
from celery.signals import task_failure
|
||||
@@ -16,25 +17,29 @@ from celery.signals import task_prerun
|
||||
from celery.signals import task_revoked
|
||||
from celery.signals import worker_process_init
|
||||
from celery.signals import worker_process_shutdown
|
||||
from channels.layers import get_channel_layer
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import DatabaseError
|
||||
from django.db import close_old_connections
|
||||
from django.db import connections
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents import matching
|
||||
from documents.caching import clear_document_caches
|
||||
from documents.caching import invalidate_llm_suggestions_cache
|
||||
from documents.caching import invalidate_suggestions_cache
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_moves import move_files_for_document
|
||||
from documents.file_moves import request_file_move
|
||||
from documents.media_lock import media_lock
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -52,6 +57,7 @@ from documents.models import WorkflowTrigger
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.utils import compute_checksum
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from documents.workflows.actions import build_workflow_action_context
|
||||
from documents.workflows.actions import execute_email_action
|
||||
@@ -335,7 +341,7 @@ def set_storage_path(
|
||||
|
||||
# see empty_trash in documents/tasks.py for signal handling
|
||||
def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
||||
with media_lock():
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
if settings.EMPTY_TRASH_DIR:
|
||||
# Find a non-conflicting filename in case a document with the same
|
||||
# name was moved to trash earlier
|
||||
@@ -401,6 +407,17 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
|
||||
)
|
||||
|
||||
|
||||
class CannotMoveFilesException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
|
||||
if checksum is None or not path.is_file():
|
||||
return False
|
||||
|
||||
return compute_checksum(path) == checksum
|
||||
|
||||
|
||||
def _filename_template_uses_custom_fields(doc: Document) -> bool:
|
||||
template = None
|
||||
if doc.storage_path is not None:
|
||||
@@ -423,24 +440,235 @@ def update_filename_and_move_files(
|
||||
instance: Document | CustomFieldInstance,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
action = kwargs.get("action")
|
||||
if action is not None and action not in {"post_add", "post_remove", "post_clear"}:
|
||||
# m2m_changed fires pre_* and post_* actions; only post_* describe the
|
||||
# relation state the filename should follow.
|
||||
return
|
||||
|
||||
if kwargs.get("reverse"):
|
||||
# tag.documents.add(doc): instance is the Tag, pk_set holds Document pks.
|
||||
for document in Document.objects.filter(pk__in=kwargs.get("pk_set") or ()):
|
||||
request_file_move(document)
|
||||
return
|
||||
|
||||
if isinstance(instance, CustomFieldInstance):
|
||||
if not _filename_template_uses_custom_fields(instance.document):
|
||||
return
|
||||
instance = instance.document
|
||||
|
||||
request_file_move(instance)
|
||||
def validate_move(instance, old_path: Path, new_path: Path, root: Path) -> None:
|
||||
if not new_path.is_relative_to(root):
|
||||
msg = (
|
||||
f"Document {instance!s}: Refusing to move file outside root {root}: "
|
||||
f"{new_path}."
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if not old_path.is_file():
|
||||
# Can't do anything if the old file does not exist anymore.
|
||||
msg = f"Document {instance!s}: File {old_path} doesn't exist."
|
||||
logger.fatal(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if new_path.is_file():
|
||||
# Can't do anything if the new file already exists. Skip updating file.
|
||||
msg = f"Document {instance!s}: Cannot rename file since target path {new_path} already exists."
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
if not instance.filename:
|
||||
# Can't update the filename if there is no filename to begin with
|
||||
# This happens when the consumer creates a new document.
|
||||
# The document is modified and saved multiple times, and only after
|
||||
# everything is done (i.e., the generated filename is final),
|
||||
# filename will be set to the location where the consumer has put
|
||||
# the file.
|
||||
#
|
||||
# This will in turn cause this logic to move the file where it belongs.
|
||||
return
|
||||
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
# If this was waiting for the lock, the filename or archive_filename
|
||||
# of this document may have been updated. This happens if multiple updates
|
||||
# get queued from the UI for the same document
|
||||
# So freshen up the data before doing anything
|
||||
instance.refresh_from_db()
|
||||
|
||||
try:
|
||||
old_filename = instance.filename
|
||||
old_source_path = instance.source_path
|
||||
move_original = False
|
||||
original_already_moved = False
|
||||
|
||||
old_archive_filename = instance.archive_filename
|
||||
old_archive_path = instance.archive_path
|
||||
move_archive = False
|
||||
archive_already_moved = False
|
||||
|
||||
candidate_filename = generate_filename(instance)
|
||||
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {instance!s}: Generated filename exceeds db path "
|
||||
f"limit ({len(str(candidate_filename))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
|
||||
candidate_source_path = (
|
||||
settings.ORIGINALS_DIR / candidate_filename
|
||||
).resolve()
|
||||
if candidate_filename == Path(old_filename):
|
||||
new_filename = Path(old_filename)
|
||||
elif (
|
||||
candidate_source_path.exists()
|
||||
and candidate_source_path != old_source_path
|
||||
):
|
||||
if not old_source_path.is_file() and _path_matches_checksum(
|
||||
candidate_source_path,
|
||||
instance.checksum,
|
||||
):
|
||||
new_filename = candidate_filename
|
||||
original_already_moved = True
|
||||
else:
|
||||
# Only fall back to unique search when there is an actual conflict
|
||||
new_filename = generate_unique_filename(instance)
|
||||
else:
|
||||
new_filename = candidate_filename
|
||||
|
||||
# Need to convert to string to be able to save it to the db
|
||||
instance.filename = str(new_filename)
|
||||
move_original = (
|
||||
old_filename != instance.filename and not original_already_moved
|
||||
)
|
||||
|
||||
if instance.has_archive_version:
|
||||
archive_candidate = generate_filename(instance, archive_filename=True)
|
||||
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
msg = (
|
||||
f"Document {instance!s}: Generated archive filename exceeds "
|
||||
f"db path limit ({len(str(archive_candidate))} > "
|
||||
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
|
||||
)
|
||||
logger.warning(msg)
|
||||
raise CannotMoveFilesException(msg)
|
||||
archive_candidate_path = (
|
||||
settings.ARCHIVE_DIR / archive_candidate
|
||||
).resolve()
|
||||
if archive_candidate == Path(old_archive_filename):
|
||||
new_archive_filename = Path(old_archive_filename)
|
||||
elif (
|
||||
archive_candidate_path.exists()
|
||||
and archive_candidate_path != old_archive_path
|
||||
):
|
||||
if not old_archive_path.is_file() and _path_matches_checksum(
|
||||
archive_candidate_path,
|
||||
instance.archive_checksum,
|
||||
):
|
||||
new_archive_filename = archive_candidate
|
||||
archive_already_moved = True
|
||||
else:
|
||||
new_archive_filename = generate_unique_filename(
|
||||
instance,
|
||||
archive_filename=True,
|
||||
)
|
||||
else:
|
||||
new_archive_filename = archive_candidate
|
||||
|
||||
instance.archive_filename = str(new_archive_filename)
|
||||
|
||||
move_archive = (
|
||||
old_archive_filename != instance.archive_filename
|
||||
and not archive_already_moved
|
||||
)
|
||||
else:
|
||||
move_archive = False
|
||||
|
||||
if not move_original and not move_archive:
|
||||
updates = {"modified": timezone.now()}
|
||||
if old_filename != instance.filename:
|
||||
updates["filename"] = instance.filename
|
||||
if old_archive_filename != instance.archive_filename:
|
||||
updates["archive_filename"] = instance.archive_filename
|
||||
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.objects.filter(pk=instance.pk).update(**updates)
|
||||
return
|
||||
|
||||
if move_original:
|
||||
validate_move(
|
||||
instance,
|
||||
old_source_path,
|
||||
instance.source_path,
|
||||
settings.ORIGINALS_DIR,
|
||||
)
|
||||
create_source_path_directory(instance.source_path)
|
||||
shutil.move(old_source_path, instance.source_path)
|
||||
|
||||
if move_archive:
|
||||
validate_move(
|
||||
instance,
|
||||
old_archive_path,
|
||||
instance.archive_path,
|
||||
settings.ARCHIVE_DIR,
|
||||
)
|
||||
create_source_path_directory(instance.archive_path)
|
||||
shutil.move(old_archive_path, instance.archive_path)
|
||||
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.global_objects.filter(pk=instance.pk).update(
|
||||
filename=instance.filename,
|
||||
archive_filename=instance.archive_filename,
|
||||
modified=timezone.now(),
|
||||
)
|
||||
# Clear any caching for this document. Slightly overkill, but not terrible
|
||||
clear_document_caches(instance.pk)
|
||||
|
||||
except (OSError, DatabaseError, CannotMoveFilesException) as e:
|
||||
logger.warning(f"Exception during file handling: {e}")
|
||||
# This happens when either:
|
||||
# - moving the files failed due to file system errors
|
||||
# - saving to the database failed due to database errors
|
||||
# In both cases, we need to revert to the original state.
|
||||
|
||||
# Try to move files to their original location.
|
||||
try:
|
||||
if move_original and instance.source_path.is_file():
|
||||
logger.info("Restoring previous original path")
|
||||
shutil.move(instance.source_path, old_source_path)
|
||||
|
||||
if move_archive and instance.archive_path.is_file():
|
||||
logger.info("Restoring previous archive path")
|
||||
shutil.move(instance.archive_path, old_archive_path)
|
||||
|
||||
except Exception:
|
||||
# This is fine, since:
|
||||
# A: if we managed to move source from A to B, we will also
|
||||
# manage to move it from B to A. If not, we have a serious
|
||||
# issue that's going to get caught by the santiy checker.
|
||||
# All files remain in place and will never be overwritten,
|
||||
# so this is not the end of the world.
|
||||
# B: if moving the original file failed, nothing has changed
|
||||
# anyway.
|
||||
pass
|
||||
|
||||
# restore old values on the instance
|
||||
instance.filename = old_filename
|
||||
instance.archive_filename = old_archive_filename
|
||||
|
||||
# finally, remove any empty sub folders. This will do nothing if
|
||||
# something has failed above.
|
||||
if not old_source_path.is_file():
|
||||
delete_empty_directories(
|
||||
Path(old_source_path).parent,
|
||||
root=settings.ORIGINALS_DIR,
|
||||
)
|
||||
|
||||
if instance.has_archive_version and not old_archive_path.is_file():
|
||||
delete_empty_directories(
|
||||
Path(old_archive_path).parent,
|
||||
root=settings.ARCHIVE_DIR,
|
||||
)
|
||||
|
||||
# Keep version files in sync with root
|
||||
if instance.root_document_id is None:
|
||||
for version_doc in Document.objects.filter(root_document_id=instance.pk).only(
|
||||
"pk",
|
||||
):
|
||||
update_filename_and_move_files(
|
||||
Document,
|
||||
version_doc,
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -465,8 +693,7 @@ def process_cf_select_update(custom_field: CustomField) -> None:
|
||||
|
||||
for cf_instance in custom_field.fields.select_related("document").iterator():
|
||||
# Update the filename and move files if necessary
|
||||
if _filename_template_uses_custom_fields(cf_instance.document):
|
||||
move_files_for_document(cf_instance.document_id)
|
||||
update_filename_and_move_files(CustomFieldInstance, cf_instance)
|
||||
|
||||
|
||||
# should be disabled in /src/documents/management/commands/document_importer.py handle
|
||||
@@ -839,6 +1066,7 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
|
||||
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
|
||||
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
|
||||
"documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS,
|
||||
"documents.bulk_operations.execute_bulk_operation": PaperlessTask.TaskType.BULK_UPDATE,
|
||||
}
|
||||
|
||||
_CELERY_STATE_TO_STATUS: dict[str, PaperlessTask.Status] = {
|
||||
@@ -856,8 +1084,8 @@ def _extract_input_data(
|
||||
|
||||
For consume_file tasks this includes the filename, MIME type, and any
|
||||
non-null overrides from the DocumentMetadataOverrides object. For
|
||||
mail_fetch tasks it captures the account_ids list. All other task
|
||||
types store no input data and return {}.
|
||||
mail_fetch tasks it captures the account_ids list. Bulk operations store
|
||||
the operation name and document count, without copying the full selection.
|
||||
"""
|
||||
if task_type == PaperlessTask.TaskType.CONSUME_FILE:
|
||||
input_doc = task_kwargs.get("input_doc")
|
||||
@@ -886,6 +1114,12 @@ def _extract_input_data(
|
||||
data["overrides"] = override_dict
|
||||
return data
|
||||
|
||||
if "operation" in task_kwargs and "user_id" in task_kwargs:
|
||||
return {
|
||||
"operation": task_kwargs["operation"],
|
||||
"document_count": len(task_kwargs.get("document_ids", [])),
|
||||
}
|
||||
|
||||
if task_type == PaperlessTask.TaskType.MAIL_FETCH:
|
||||
account_ids = task_kwargs.get("account_ids")
|
||||
if account_ids is not None:
|
||||
@@ -922,7 +1156,9 @@ def _extract_owner_id(
|
||||
task_type: PaperlessTask.TaskType,
|
||||
task_kwargs: dict,
|
||||
) -> int | None:
|
||||
"""Return the owner_id from consume_file overrides, or None for all other task types."""
|
||||
"""Use the initiator for bulk operations and the document owner for consumption."""
|
||||
if "operation" in task_kwargs:
|
||||
return task_kwargs.get("user_id")
|
||||
if task_type != PaperlessTask.TaskType.CONSUME_FILE:
|
||||
return None
|
||||
overrides = task_kwargs.get("overrides")
|
||||
@@ -1003,6 +1239,29 @@ def task_prerun_handler(sender=None, task_id=None, task=None, **kwargs) -> None:
|
||||
logger.exception("Setting PaperlessTask started failed")
|
||||
|
||||
|
||||
def _send_bulk_operation_status(task_id: str) -> None:
|
||||
"""Notify the initiator, task records still happen."""
|
||||
try:
|
||||
task = PaperlessTask.objects.filter(task_id=task_id).first()
|
||||
if task is None or task.owner_id is None or "operation" not in task.input_data:
|
||||
return
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f"bulk_operations_{task.owner_id}",
|
||||
{
|
||||
"type": "bulk_operation_status",
|
||||
"data": {
|
||||
"task_id": task.task_id,
|
||||
"status": task.status,
|
||||
"operation": task.input_data["operation"],
|
||||
"error": (task.result_data or {}).get("error_message", ""),
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
# Just a notification failure
|
||||
logger.exception("Sending bulk operation status failed")
|
||||
|
||||
|
||||
@task_postrun.connect
|
||||
def task_postrun_handler(
|
||||
sender=None,
|
||||
@@ -1060,6 +1319,7 @@ def task_postrun_handler(
|
||||
changed_fields.append("status")
|
||||
|
||||
task_instance.save(update_fields=changed_fields)
|
||||
_send_bulk_operation_status(task_id)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Updating PaperlessTask failed")
|
||||
|
||||
@@ -1125,6 +1385,7 @@ def task_failure_handler(
|
||||
date_started - date_created
|
||||
).total_seconds()
|
||||
task_qs.update(**update_fields)
|
||||
_send_bulk_operation_status(task_id)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Updating PaperlessTask on failure failed")
|
||||
|
||||
@@ -1168,6 +1429,7 @@ def task_revoked_handler(
|
||||
"reason": (reason + " Check worker logs for details."),
|
||||
},
|
||||
)
|
||||
_send_bulk_operation_status(task_id)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Updating PaperlessTask on revocation failed")
|
||||
|
||||
|
||||
+8
-26
@@ -16,6 +16,7 @@ from django.db import models
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_save
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
|
||||
from documents import sanity_checker
|
||||
from documents.barcodes import BarcodePlugin
|
||||
@@ -41,7 +42,6 @@ from documents.double_sided import CollatePlugin
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.matching import prefilter_documents_by_workflowtrigger
|
||||
from documents.media_lock import media_lock
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
@@ -310,7 +310,6 @@ def sanity_check(*, raise_on_error: bool = True) -> str:
|
||||
|
||||
@shared_task
|
||||
def bulk_update_documents(document_ids) -> None:
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
from documents.search import get_backend
|
||||
|
||||
document_ids = list(document_ids)
|
||||
@@ -331,9 +330,6 @@ def bulk_update_documents(document_ids) -> None:
|
||||
skip_ai_index=True, # bulk path calls update_llm_index once below
|
||||
)
|
||||
post_save.send(Document, instance=doc, created=False)
|
||||
# Move this document's files now rather than at the end of the task,
|
||||
# so a long bulk edit makes steady progress.
|
||||
flush_pending_file_moves()
|
||||
|
||||
with get_backend().batch_update() as batch:
|
||||
batch.add_or_update_ids(document_ids)
|
||||
@@ -421,13 +417,7 @@ def update_document_content_maybe_archive_file(
|
||||
|
||||
thumbnail = parser.get_thumbnail(document.source_path, mime_type)
|
||||
|
||||
# media.lock first, then the transaction: never wait on media.lock
|
||||
# while this transaction holds the document row. Also keeps the
|
||||
# unique archive filename generation under the lock.
|
||||
with media_lock(), transaction.atomic():
|
||||
# Loaded before the (long) parse; read current metadata now
|
||||
# that the lock is held, for the archive filename.
|
||||
document.refresh_from_db()
|
||||
with transaction.atomic():
|
||||
oldDocument = Document.objects.get(pk=document.pk)
|
||||
if parser.get_archive_path():
|
||||
checksum = compute_checksum(parser.get_archive_path())
|
||||
@@ -492,10 +482,11 @@ def update_document_content_maybe_archive_file(
|
||||
modified=timezone.now(),
|
||||
)
|
||||
|
||||
if parser.get_archive_path():
|
||||
create_source_path_directory(document.archive_path)
|
||||
shutil.move(parser.get_archive_path(), document.archive_path)
|
||||
shutil.move(thumbnail, document.thumbnail_path)
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
if parser.get_archive_path():
|
||||
create_source_path_directory(document.archive_path)
|
||||
shutil.move(parser.get_archive_path(), document.archive_path)
|
||||
shutil.move(thumbnail, document.thumbnail_path)
|
||||
|
||||
document.refresh_from_db()
|
||||
logger.info(
|
||||
@@ -540,11 +531,7 @@ def empty_trash(doc_ids=None) -> None:
|
||||
deleted_document_ids = list(documents.values_list("id", flat=True))
|
||||
# Temporarily connect the cleanup handler
|
||||
models.signals.post_delete.connect(cleanup_document_deletion, sender=Document)
|
||||
# media.lock before Django's delete transaction: post_delete runs
|
||||
# cleanup_document_deletion inside it while the rows are locked, and
|
||||
# that handler re-enters the lock instead of waiting.
|
||||
with media_lock():
|
||||
documents.delete() # this is effectively a hard delete
|
||||
documents.delete() # this is effectively a hard delete
|
||||
logger.info(f"Deleted {len(deleted_document_ids)} documents from trash")
|
||||
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
@@ -574,8 +561,6 @@ def check_scheduled_workflows() -> None:
|
||||
|
||||
Once a document satisfies this condition, and recurring/non-recurring constraints are met, the workflow is run.
|
||||
"""
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
|
||||
scheduled_workflows = get_workflows_for_trigger(
|
||||
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||
)
|
||||
@@ -703,9 +688,6 @@ def check_scheduled_workflows() -> None:
|
||||
sender=None,
|
||||
document=document,
|
||||
)
|
||||
# Move this document's files now rather than at the end
|
||||
# of the task, so a long run makes steady progress.
|
||||
flush_pending_file_moves()
|
||||
|
||||
|
||||
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.test import override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.bulk_operations import execute_bulk_operation
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
@@ -29,9 +30,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.user = user
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
patcher = mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
patcher = mock.patch("documents.bulk_operations.bulk_update_documents")
|
||||
self.async_task = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
# Exercise accepted requests and worker execution together in these
|
||||
# operation/validation tests. Queue-only behavior is tested separately.
|
||||
enqueue = mock.patch(
|
||||
"documents.views.execute_bulk_operation.apply_async",
|
||||
side_effect=lambda *, kwargs, headers: self.run_operation(kwargs),
|
||||
)
|
||||
enqueue.start()
|
||||
self.addCleanup(enqueue.stop)
|
||||
|
||||
self.c1 = Correspondent.objects.create(name="c1")
|
||||
self.c2 = Correspondent.objects.create(name="c2")
|
||||
self.dt1 = DocumentType.objects.create(name="dt1")
|
||||
@@ -61,11 +71,16 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.cf1 = CustomField.objects.create(name="cf1", data_type="string")
|
||||
self.cf2 = CustomField.objects.create(name="cf2", data_type="string")
|
||||
|
||||
@staticmethod
|
||||
def run_operation(kwargs):
|
||||
execute_bulk_operation(**kwargs)
|
||||
return mock.Mock(id="test-bulk-operation")
|
||||
|
||||
def setup_mock(self, m, method_name, return_value="OK") -> None:
|
||||
m.return_value = return_value
|
||||
m.__name__ = method_name
|
||||
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_operations.bulk_update_documents")
|
||||
def test_api_set_correspondent(self, bulk_update_task_mock) -> None:
|
||||
self.assertNotEqual(self.doc1.correspondent, self.c1)
|
||||
response = self.client.post(
|
||||
@@ -84,11 +99,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(self.doc1.correspondent, self.c1)
|
||||
bulk_update_task_mock.assert_called_once()
|
||||
self.assertCountEqual(
|
||||
bulk_update_task_mock.call_args.kwargs["kwargs"]["document_ids"],
|
||||
bulk_update_task_mock.call_args.args[0],
|
||||
[self.doc1.pk],
|
||||
)
|
||||
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_operations.bulk_update_documents")
|
||||
def test_api_unset_correspondent(self, bulk_update_task_mock) -> None:
|
||||
self.doc1.correspondent = self.c1
|
||||
self.doc1.save()
|
||||
@@ -110,7 +125,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.doc1.refresh_from_db()
|
||||
self.assertIsNone(self.doc1.correspondent)
|
||||
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_operations.bulk_update_documents")
|
||||
def test_api_set_type(self, bulk_update_task_mock) -> None:
|
||||
self.assertNotEqual(self.doc1.document_type, self.dt1)
|
||||
response = self.client.post(
|
||||
@@ -129,11 +144,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(self.doc1.document_type, self.dt1)
|
||||
bulk_update_task_mock.assert_called_once()
|
||||
self.assertCountEqual(
|
||||
bulk_update_task_mock.call_args.kwargs["kwargs"]["document_ids"],
|
||||
bulk_update_task_mock.call_args.args[0],
|
||||
[self.doc1.pk],
|
||||
)
|
||||
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_operations.bulk_update_documents")
|
||||
def test_api_unset_type(self, bulk_update_task_mock) -> None:
|
||||
self.doc1.document_type = self.dt1
|
||||
self.doc1.save()
|
||||
@@ -154,11 +169,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertIsNone(self.doc1.document_type)
|
||||
bulk_update_task_mock.assert_called_once()
|
||||
self.assertCountEqual(
|
||||
bulk_update_task_mock.call_args.kwargs["kwargs"]["document_ids"],
|
||||
bulk_update_task_mock.call_args.args[0],
|
||||
[self.doc1.pk],
|
||||
)
|
||||
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_operations.bulk_update_documents")
|
||||
def test_api_add_tag(self, bulk_update_task_mock) -> None:
|
||||
self.assertFalse(self.doc1.tags.filter(pk=self.t1.pk).exists())
|
||||
|
||||
@@ -180,11 +195,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
|
||||
bulk_update_task_mock.assert_called_once()
|
||||
self.assertCountEqual(
|
||||
bulk_update_task_mock.call_args.kwargs["kwargs"]["document_ids"],
|
||||
bulk_update_task_mock.call_args.args[0],
|
||||
[self.doc1.pk],
|
||||
)
|
||||
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.bulk_operations.bulk_update_documents")
|
||||
def test_api_remove_tag(self, bulk_update_task_mock) -> None:
|
||||
self.doc1.tags.add(self.t1)
|
||||
|
||||
@@ -223,6 +238,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertListEqual(args[0], [self.doc1.id, self.doc3.id])
|
||||
self.assertEqual(kwargs["add_tags"], [self.t1.id])
|
||||
self.assertEqual(kwargs["remove_tags"], [self.t2.id])
|
||||
@@ -277,6 +295,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertListEqual(args[0], [self.doc1.id, self.doc3.id])
|
||||
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
|
||||
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
||||
@@ -345,6 +366,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertListEqual(args[0], [self.doc1.id, self.doc3.id])
|
||||
self.assertEqual(kwargs["add_custom_fields"], {self.cf1.id: "foo"})
|
||||
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
|
||||
@@ -506,6 +530,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(len(kwargs), 0)
|
||||
|
||||
@@ -520,6 +547,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(len(kwargs), 0)
|
||||
|
||||
@@ -544,6 +574,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(len(kwargs), 0)
|
||||
|
||||
@@ -558,6 +591,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(kwargs, {"remote_ocr": False})
|
||||
|
||||
@@ -580,6 +616,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(kwargs, {"remote_ocr": True})
|
||||
|
||||
@@ -609,6 +648,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
|
||||
self.assertListEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
@@ -639,6 +681,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
|
||||
self.assertListEqual(args[0], [self.doc1.id])
|
||||
self.assertEqual(kwargs["storage_path"], None)
|
||||
@@ -761,6 +806,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@@ -784,6 +832,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertCountEqual(args[0], [self.doc1.id, self.doc3.id, self.doc5.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@@ -820,6 +871,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertCountEqual(args[0], [first_duplicate.id, second_duplicate.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@@ -856,6 +910,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
get_backend.return_value.search_ids.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value
|
||||
for key, value in kwargs.items()
|
||||
if key != "update_callback"
|
||||
}
|
||||
self.assertEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@@ -1232,6 +1291,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
||||
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
|
||||
|
||||
@@ -1644,6 +1706,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
||||
self.assertEqual(kwargs["degrees"], 90)
|
||||
self.assertEqual(kwargs["source_mode"], "latest_version")
|
||||
@@ -1930,6 +1995,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
|
||||
self.assertEqual(kwargs["metadata_document_id"], self.doc3.id)
|
||||
self.assertEqual(kwargs["source_mode"], "latest_version")
|
||||
@@ -2137,6 +2205,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertCountEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["operations"], [{"page": 1}])
|
||||
self.assertEqual(kwargs["source_mode"], "explicit_selection")
|
||||
@@ -2482,6 +2553,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
kwargs = {
|
||||
key: value for key, value in kwargs.items() if key != "update_callback"
|
||||
}
|
||||
self.assertCountEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["password"], "secret")
|
||||
self.assertTrue(kwargs["update_document"])
|
||||
|
||||
@@ -149,10 +149,10 @@ class TestPostSelectionPathsEnforceTheCap:
|
||||
assert str(_MAX_QUERY_LENGTH + 1) in message
|
||||
parse_spy.assert_not_called()
|
||||
|
||||
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
|
||||
@mock.patch("documents.views.execute_bulk_operation.apply_async")
|
||||
def test_bulk_edit_query_at_exactly_the_cap_is_accepted(
|
||||
self,
|
||||
bulk_update_task_mock: mock.MagicMock,
|
||||
enqueue_mock: mock.MagicMock,
|
||||
admin_client: APIClient,
|
||||
searchable_document: Document,
|
||||
) -> None:
|
||||
@@ -163,12 +163,12 @@ class TestPostSelectionPathsEnforceTheCap:
|
||||
- Its `filters.query` is exactly `_MAX_QUERY_LENGTH` characters
|
||||
long
|
||||
THEN:
|
||||
- The cap check accepts it and the request reaches the real
|
||||
bulk-edit method (its Celery dispatch is mocked out here,
|
||||
same as every other bulk-edit test, since nothing here is
|
||||
testing that method itself)
|
||||
- The cap check accepts it and queues the metadata operation.
|
||||
Mock the executor dispatch so this exercises query validation
|
||||
without requiring a broker.
|
||||
"""
|
||||
query = "a" * _MAX_QUERY_LENGTH
|
||||
enqueue_mock.return_value.id = "query-cap-task"
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
@@ -183,6 +183,11 @@ class TestPostSelectionPathsEnforceTheCap:
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"result": "OK", "task_id": "query-cap-task"}
|
||||
enqueue_mock.assert_called_once()
|
||||
assert (
|
||||
enqueue_mock.call_args.kwargs["kwargs"]["operation"] == "set_document_type"
|
||||
)
|
||||
|
||||
def test_bulk_download_query_one_over_the_cap_is_a_400(
|
||||
self,
|
||||
|
||||
@@ -739,7 +739,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
self.doc1.archive_filename = sample1_archive
|
||||
self.doc1.save(write_file_paths=True)
|
||||
self.doc1.save()
|
||||
self.doc2 = Document.objects.create(
|
||||
checksum="B",
|
||||
title="B",
|
||||
@@ -749,7 +749,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
created=date(2023, 1, 2),
|
||||
)
|
||||
self.doc2.archive_filename = sample2_archive
|
||||
self.doc2.save(write_file_paths=True)
|
||||
self.doc2.save()
|
||||
self.doc3 = Document.objects.create(
|
||||
checksum="C",
|
||||
title="C",
|
||||
@@ -774,7 +774,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
created=date(2023, 1, 3),
|
||||
)
|
||||
self.img_doc.archive_filename = img_doc_archive
|
||||
self.img_doc.save(write_file_paths=True)
|
||||
self.img_doc.save()
|
||||
|
||||
@staticmethod
|
||||
def fake_decrypt(
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Request acceptance, worker execution, and task tracking."""
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from auditlog.models import LogEntry
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.bulk_operations import execute_bulk_operation
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import PaperlessTask
|
||||
from documents.signals.handlers import before_task_publish_handler
|
||||
from documents.signals.handlers import task_failure_handler
|
||||
from documents.signals.handlers import task_postrun_handler
|
||||
from paperless_testing.factories import UserFactory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bulk_setup(paperless_dirs):
|
||||
user = UserFactory(superuser=True)
|
||||
client = APIClient()
|
||||
client.force_authenticate(user)
|
||||
doc = Document.objects.create(title="Example", checksum="bulk-op-example")
|
||||
correspondent = Correspondent.objects.create(name="Example correspondent")
|
||||
return user, client, doc, correspondent
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint,operation,parameters",
|
||||
[
|
||||
("bulk_edit", "set_correspondent", {"correspondent": None}),
|
||||
],
|
||||
)
|
||||
def test_request_only_queues(bulk_setup, endpoint, operation, parameters):
|
||||
user, client, doc, _ = bulk_setup
|
||||
task_id = str(uuid.uuid4())
|
||||
data = {"documents": [doc.pk], "from_webui": True}
|
||||
if endpoint == "bulk_edit":
|
||||
data.update(method=operation, parameters=parameters)
|
||||
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||
enqueue.return_value.id = task_id
|
||||
response = client.post(f"/api/documents/{endpoint}/", data, format="json")
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"result": "OK", "task_id": task_id}
|
||||
assert Document.objects.filter(pk=doc.pk).exists()
|
||||
assert enqueue.call_args.kwargs["kwargs"] == {
|
||||
"operation": operation,
|
||||
"document_ids": [doc.pk],
|
||||
"parameters": parameters,
|
||||
"user_id": user.pk,
|
||||
}
|
||||
assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_metadata_task_includes_audit_and_followup(bulk_setup, settings):
|
||||
user, _, doc, correspondent = bulk_setup
|
||||
settings.AUDIT_LOG_ENABLED = True
|
||||
with (
|
||||
mock.patch("documents.bulk_operations.bulk_update_documents") as followup,
|
||||
mock.patch(
|
||||
"documents.bulk_edit.bulk_update_documents.apply_async",
|
||||
) as extra_task,
|
||||
):
|
||||
|
||||
def check_followup(ids):
|
||||
doc.refresh_from_db()
|
||||
assert doc.correspondent_id == correspondent.pk
|
||||
assert LogEntry.objects.filter(object_pk=str(doc.pk), actor=user).exists()
|
||||
assert ids == [doc.pk]
|
||||
|
||||
followup.side_effect = check_followup
|
||||
result = execute_bulk_operation(
|
||||
"set_correspondent",
|
||||
[doc.pk],
|
||||
{"correspondent": correspondent.pk},
|
||||
user.pk,
|
||||
)
|
||||
followup.assert_called_once()
|
||||
extra_task.assert_not_called()
|
||||
assert result["document_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_permissions_rechecked_on_worker(bulk_setup):
|
||||
user, _, doc, correspondent = bulk_setup
|
||||
User.objects.filter(pk=user.pk).update(is_superuser=False)
|
||||
with pytest.raises(PermissionDenied):
|
||||
execute_bulk_operation(
|
||||
"set_correspondent",
|
||||
[doc.pk],
|
||||
{"correspondent": correspondent.pk},
|
||||
user.pk,
|
||||
)
|
||||
doc.refresh_from_db()
|
||||
assert doc.correspondent_id is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_queue_failure_does_not_change_documents(bulk_setup):
|
||||
_, client, doc, correspondent = bulk_setup
|
||||
with mock.patch(
|
||||
"documents.views.execute_bulk_operation.apply_async",
|
||||
side_effect=RuntimeError("broker unavailable"),
|
||||
):
|
||||
response = client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
{
|
||||
"documents": [doc.pk],
|
||||
"method": "set_correspondent",
|
||||
"parameters": {"correspondent": correspondent.pk},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
doc.refresh_from_db()
|
||||
assert doc.correspondent_id is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("failed", [False, True])
|
||||
def test_tracked_outcome_records_initiator_and_result(bulk_setup, failed):
|
||||
user, _, doc, _ = bulk_setup
|
||||
task_id = str(uuid.uuid4())
|
||||
before_task_publish_handler(
|
||||
headers={
|
||||
"task": "documents.bulk_operations.execute_bulk_operation",
|
||||
"id": task_id,
|
||||
},
|
||||
body=(
|
||||
(),
|
||||
{
|
||||
"operation": "set_permissions",
|
||||
"document_ids": [doc.pk],
|
||||
"user_id": user.pk,
|
||||
},
|
||||
{},
|
||||
),
|
||||
)
|
||||
task = PaperlessTask.objects.get(task_id=task_id)
|
||||
assert task.owner_id == user.pk
|
||||
assert task.task_type == PaperlessTask.TaskType.BULK_UPDATE
|
||||
if failed:
|
||||
task_failure_handler(task_id=task_id, exception=ValueError("Could not update"))
|
||||
else:
|
||||
task_postrun_handler(
|
||||
task_id=task_id,
|
||||
state="SUCCESS",
|
||||
retval={"document_count": 1},
|
||||
)
|
||||
task.refresh_from_db()
|
||||
assert task.status == ("failure" if failed else "success")
|
||||
if failed:
|
||||
assert task.result_data["error_message"] == "Could not update"
|
||||
else:
|
||||
assert task.result_data == {"document_count": 1}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_large_filtered_selection_is_one_task(bulk_setup):
|
||||
_, client, _, _ = bulk_setup
|
||||
Document.objects.bulk_create(
|
||||
[
|
||||
Document(title="bulk-large-selection", checksum=f"large-{i}")
|
||||
for i in range(10_000)
|
||||
],
|
||||
)
|
||||
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||
enqueue.return_value.id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
{
|
||||
"all": True,
|
||||
"filters": {"title__icontains": "bulk-large-selection"},
|
||||
"method": "set_correspondent",
|
||||
"parameters": {"correspondent": None},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
enqueue.assert_called_once()
|
||||
assert len(enqueue.call_args.kwargs["kwargs"]["document_ids"]) == 10_000
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("failed", [False, True])
|
||||
def test_tracked_outcome_notifies_only_initiator(bulk_setup, failed):
|
||||
user, _, doc, _ = bulk_setup
|
||||
task_id = str(uuid.uuid4())
|
||||
before_task_publish_handler(
|
||||
headers={
|
||||
"task": "documents.bulk_operations.execute_bulk_operation",
|
||||
"id": task_id,
|
||||
},
|
||||
body=(
|
||||
(),
|
||||
{
|
||||
"operation": "set_permissions",
|
||||
"document_ids": [doc.pk],
|
||||
"user_id": user.pk,
|
||||
},
|
||||
{},
|
||||
),
|
||||
)
|
||||
task = PaperlessTask.objects.get(task_id=task_id)
|
||||
assert task.owner_id == user.pk
|
||||
assert task.task_type == PaperlessTask.TaskType.BULK_UPDATE
|
||||
with mock.patch("documents.signals.handlers.async_to_sync") as send:
|
||||
if failed:
|
||||
task_failure_handler(
|
||||
task_id=task_id,
|
||||
exception=ValueError("Could not update"),
|
||||
)
|
||||
else:
|
||||
task_postrun_handler(
|
||||
task_id=task_id,
|
||||
state="SUCCESS",
|
||||
retval={"document_count": 1},
|
||||
)
|
||||
group, event = send.return_value.call_args.args
|
||||
assert group == f"bulk_operations_{user.pk}"
|
||||
assert event["data"]["status"] == ("failure" if failed else "success")
|
||||
assert event["data"]["task_id"] == task_id
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("anyio_backend", ["asyncio"])
|
||||
async def test_completion_socket_is_private(anyio_backend):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from channels.layers import get_channel_layer
|
||||
from channels.testing import WebsocketCommunicator
|
||||
|
||||
from paperless.consumers import StatusConsumer
|
||||
|
||||
own = WebsocketCommunicator(StatusConsumer.as_asgi(), "/ws/status/")
|
||||
other = WebsocketCommunicator(StatusConsumer.as_asgi(), "/ws/status/")
|
||||
own.scope["user"] = SimpleNamespace(id=1, is_authenticated=True)
|
||||
other.scope["user"] = SimpleNamespace(id=2, is_authenticated=True)
|
||||
assert (await own.connect())[0]
|
||||
assert (await other.connect())[0]
|
||||
try:
|
||||
event = {
|
||||
"type": "bulk_operation_status",
|
||||
"data": {"task_id": "example", "status": "success"},
|
||||
}
|
||||
await get_channel_layer().group_send("bulk_operations_1", event)
|
||||
assert await own.receive_json_from() == event
|
||||
assert await other.receive_nothing(timeout=0.02)
|
||||
finally:
|
||||
await own.disconnect()
|
||||
await other.disconnect()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("endpoint", ["delete", "bulk_edit"])
|
||||
def test_trash_remains_synchronous(bulk_setup, endpoint):
|
||||
_, client, doc, _ = bulk_setup
|
||||
data = {"documents": [doc.pk]}
|
||||
if endpoint == "bulk_edit":
|
||||
data.update(method="delete", parameters={})
|
||||
with (
|
||||
mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue,
|
||||
mock.patch("documents.search.get_backend"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/documents/{endpoint}/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_ACCEPT="application/json; version=9",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"result": "OK"}
|
||||
assert not Document.objects.filter(pk=doc.pk).exists()
|
||||
assert Document.deleted_objects.filter(pk=doc.pk).exists()
|
||||
enqueue.assert_not_called()
|
||||
@@ -27,10 +27,6 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowActionEmail
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import ParseError
|
||||
from documents.plugins.helpers import ProgressStatusOptions
|
||||
from documents.tasks import sanity_check
|
||||
@@ -42,7 +38,6 @@ from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
from paperless_testing.locks import record_media_lock_acquisitions
|
||||
from paperless_testing.permissions import grant_object
|
||||
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
|
||||
from paperless_testing.samples import MULTI_PAGE_IMAGES_PDF
|
||||
@@ -226,160 +221,6 @@ class TestConsumer(
|
||||
shutil.copy(src, dst)
|
||||
return dst
|
||||
|
||||
@mock.patch("documents.consumer.load_classifier")
|
||||
def test_consume_takes_media_lock_outside_transaction(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A new document to consume
|
||||
WHEN:
|
||||
- It is consumed
|
||||
THEN:
|
||||
- media.lock is acquired
|
||||
- media.lock is never first acquired inside a transaction
|
||||
- The document's file is in place
|
||||
"""
|
||||
m.return_value = MagicMock()
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
self.assertTrue(acquisitions)
|
||||
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
self.assertIsFile(document.source_path)
|
||||
|
||||
@mock.patch("documents.consumer.load_classifier")
|
||||
def test_consume_places_file_before_post_consume_script(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A new document to consume
|
||||
WHEN:
|
||||
- It is consumed
|
||||
THEN:
|
||||
- Pending file moves are flushed before the post-consume script runs
|
||||
"""
|
||||
m.return_value = MagicMock()
|
||||
order = MagicMock()
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.consumer.flush_pending_file_moves",
|
||||
order.flush,
|
||||
),
|
||||
mock.patch.object(
|
||||
ConsumerPlugin,
|
||||
"run_post_consume_script",
|
||||
order.script,
|
||||
),
|
||||
):
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
self.assertEqual(
|
||||
[c[0] for c in order.mock_calls],
|
||||
["flush", "script"],
|
||||
)
|
||||
|
||||
@mock.patch("documents.consumer.load_classifier")
|
||||
def test_consume_version_takes_media_lock_outside_transaction(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An existing root document
|
||||
WHEN:
|
||||
- A new version of it is consumed (the root row is locked with
|
||||
select_for_update inside the consumer's transaction)
|
||||
THEN:
|
||||
- media.lock is never first acquired inside a transaction
|
||||
- The root's modified is bumped
|
||||
"""
|
||||
m.return_value = MagicMock()
|
||||
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
root_doc = Document.objects.first()
|
||||
assert root_doc is not None
|
||||
original_modified = timezone.now() - datetime.timedelta(days=1)
|
||||
Document.objects.filter(pk=root_doc.pk).update(modified=original_modified)
|
||||
|
||||
version_file = self.get_test_file2()
|
||||
status = FakeProgressManager(version_file.name, None)
|
||||
overrides = DocumentMetadataOverrides(version_label="v2")
|
||||
doc = ConsumableDocument(
|
||||
DocumentSource.ApiUpload,
|
||||
original_file=version_file,
|
||||
root_document_id=root_doc.pk,
|
||||
)
|
||||
preflight = ConsumerPreflightPlugin(
|
||||
doc,
|
||||
overrides,
|
||||
status, # type: ignore[arg-type]
|
||||
self.dirs.scratch_dir,
|
||||
"task-id",
|
||||
)
|
||||
preflight.setup()
|
||||
preflight.run()
|
||||
consumer = ConsumerPlugin(
|
||||
doc,
|
||||
overrides,
|
||||
status, # type: ignore[arg-type]
|
||||
self.dirs.scratch_dir,
|
||||
"task-id",
|
||||
)
|
||||
consumer.setup()
|
||||
|
||||
try:
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
consumer.run()
|
||||
finally:
|
||||
consumer.cleanup()
|
||||
|
||||
self.assertTrue(acquisitions)
|
||||
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
|
||||
root_doc.refresh_from_db()
|
||||
self.assertGreater(root_doc.modified, original_modified)
|
||||
|
||||
@override_settings(
|
||||
PAPERLESS_EMAIL_HOST="localhost",
|
||||
EMAIL_ENABLED=True,
|
||||
PAPERLESS_URL="http://localhost:8000",
|
||||
)
|
||||
@mock.patch("documents.consumer.load_classifier")
|
||||
@mock.patch("django.core.mail.message.EmailMessage.send", return_value=1)
|
||||
def test_consumption_workflow_email_runs_under_held_lock(self, send, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document added workflow with an email action
|
||||
WHEN:
|
||||
- A document is consumed (the consumer holds media.lock while
|
||||
workflows run)
|
||||
THEN:
|
||||
- The email action re-enters media.lock and sends without blocking
|
||||
"""
|
||||
m.return_value = MagicMock()
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
)
|
||||
email = WorkflowActionEmail.objects.create(
|
||||
subject="added",
|
||||
body="body",
|
||||
to="someone@example.com",
|
||||
include_document=True,
|
||||
)
|
||||
action = WorkflowAction.objects.create(
|
||||
type=WorkflowAction.WorkflowActionType.EMAIL,
|
||||
email=email,
|
||||
)
|
||||
workflow = Workflow.objects.create(name="email on add")
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(action)
|
||||
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
send.assert_called_once()
|
||||
|
||||
@override_settings(
|
||||
FILENAME_FORMAT=None,
|
||||
TIME_ZONE="America/Chicago",
|
||||
@@ -909,7 +750,7 @@ class TestConsumer(
|
||||
FILENAME_FORMAT="{correspondent}/{title}",
|
||||
ARCHIVE_FILE_GENERATION="always",
|
||||
)
|
||||
@mock.patch("documents.file_moves.generate_unique_filename")
|
||||
@mock.patch("documents.signals.handlers.generate_unique_filename")
|
||||
def testFilenameHandlingUnstableFormat(self, m) -> None:
|
||||
filenames = ["this", "that", "now this", "i cannot decide"]
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
# Ensure that filename is properly generated
|
||||
self.assertEqual(document.filename, Path("none/none.pdf"))
|
||||
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
|
||||
# test that creating dirs for the source_path creates the correct directory
|
||||
create_source_path_directory(document.source_path)
|
||||
@@ -190,7 +190,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Set a correspondent and save the document
|
||||
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
|
||||
# Check proper handling of files
|
||||
self.assertIsFile(
|
||||
@@ -232,7 +232,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
disable_auditlog(),
|
||||
):
|
||||
m.side_effect = DatabaseError()
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
|
||||
# Check proper handling of files
|
||||
self.assertIsFile(document.source_path)
|
||||
@@ -336,7 +336,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Ensure that filename is properly generated
|
||||
document.filename = generate_filename(document)
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
self.assertEqual(document.filename, "none/none.pdf")
|
||||
|
||||
create_source_path_directory(document.source_path)
|
||||
@@ -362,7 +362,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Ensure that filename is properly generated
|
||||
document.filename = generate_filename(document)
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
self.assertEqual(document.filename, "none/none.pdf")
|
||||
|
||||
create_source_path_directory(document.source_path)
|
||||
@@ -385,7 +385,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
document.save()
|
||||
document.filename = generate_filename(document)
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
create_source_path_directory(document.source_path)
|
||||
Path(document.source_path).touch()
|
||||
document.delete()
|
||||
@@ -421,7 +421,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Set a correspondent and save the document
|
||||
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
|
||||
# Check proper handling of files
|
||||
self.assertIsDir(settings.ORIGINALS_DIR / "test")
|
||||
@@ -476,7 +476,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
def test_filename_relative(self) -> None:
|
||||
doc = Document.objects.create(title="doc1", mime_type="application/pdf")
|
||||
doc.filename = generate_filename(doc)
|
||||
doc.save(write_file_paths=True)
|
||||
doc.save()
|
||||
|
||||
self.assertEqual(
|
||||
doc.source_path,
|
||||
@@ -532,7 +532,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
# Ensure that filename is properly generated
|
||||
document.filename = generate_filename(document)
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
self.assertEqual(document.filename, "none/none/none.pdf")
|
||||
create_source_path_directory(document.source_path)
|
||||
Path(document.source_path).touch()
|
||||
@@ -620,13 +620,13 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
Path(document.source_path).touch()
|
||||
Path(document2.source_path).touch()
|
||||
document.filename = "0000001.pdf"
|
||||
document.save(write_file_paths=True)
|
||||
document.save()
|
||||
|
||||
self.assertIsFile(document.source_path)
|
||||
self.assertEqual(document.filename, "qwe.pdf")
|
||||
|
||||
document2.filename = "0000002.pdf"
|
||||
document2.save(write_file_paths=True)
|
||||
document2.save()
|
||||
|
||||
self.assertIsFile(document.source_path)
|
||||
self.assertEqual(document2.filename, "qwe_01.pdf")
|
||||
@@ -656,17 +656,19 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertEqual(document2.filename, "qwe.pdf")
|
||||
|
||||
@override_settings(FILENAME_FORMAT="{title}")
|
||||
@mock.patch("documents.file_moves.Document.global_objects.filter")
|
||||
@mock.patch("documents.file_moves.shutil.move")
|
||||
@mock.patch("documents.signals.handlers.Document.objects.filter")
|
||||
@mock.patch("documents.signals.handlers.shutil.move")
|
||||
def test_no_move_only_save(self, mock_move, mock_filter) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document with a filename
|
||||
- The document is saved
|
||||
- The filename is not changed
|
||||
WHEN:
|
||||
- The document is saved without a change that affects its path
|
||||
- The document is saved
|
||||
THEN:
|
||||
- The document modified date is updated by the save itself
|
||||
- The file is not moved and the mover writes nothing
|
||||
- The document modified date is updated
|
||||
- The document is not moved
|
||||
"""
|
||||
with disable_auditlog():
|
||||
doc = Document.objects.create(
|
||||
@@ -684,7 +686,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
doc.save()
|
||||
doc.refresh_from_db()
|
||||
|
||||
mock_filter.assert_not_called()
|
||||
mock_filter.assert_called()
|
||||
self.assertNotEqual(original_modified, doc.modified)
|
||||
mock_move.assert_not_called()
|
||||
|
||||
@@ -692,7 +694,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
FILENAME_FORMAT="{{title}}_{{custom_fields|get_cf_value('test')}}",
|
||||
CELERY_TASK_ALWAYS_EAGER=True,
|
||||
)
|
||||
@mock.patch("documents.signals.handlers.move_files_for_document")
|
||||
@mock.patch("documents.signals.handlers.update_filename_and_move_files")
|
||||
def test_select_cf_updated(self, m) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -2043,7 +2045,7 @@ class TestCustomFieldFilenameUpdates(
|
||||
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(self.doc.source_path).touch()
|
||||
|
||||
with mock.patch("documents.file_moves.generate_unique_filename") as m:
|
||||
with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
|
||||
m.side_effect = generate_unique_filename
|
||||
self.cfi.value_text = "updated"
|
||||
self.cfi.save()
|
||||
@@ -2065,7 +2067,7 @@ class TestCustomFieldFilenameUpdates(
|
||||
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(self.doc.source_path).touch()
|
||||
|
||||
with mock.patch("documents.file_moves.generate_unique_filename") as m:
|
||||
with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
|
||||
m.side_effect = generate_unique_filename
|
||||
self.cfi.value_text = "updated"
|
||||
self.cfi.save()
|
||||
@@ -2143,7 +2145,7 @@ def test_refresh_failure_surfaces_database_error(
|
||||
GIVEN:
|
||||
- A document with a stored filename
|
||||
WHEN:
|
||||
- Saving it triggers a filename update and loading it from the database fails
|
||||
- Saving it triggers a filename update and refreshing it from the database fails
|
||||
THEN:
|
||||
- The database error is raised, not an unbound variable error from the rollback
|
||||
"""
|
||||
@@ -2151,8 +2153,8 @@ def test_refresh_failure_surfaces_database_error(
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
Document.global_objects,
|
||||
"get",
|
||||
Document,
|
||||
"refresh_from_db",
|
||||
side_effect=DatabaseError("database is locked"),
|
||||
),
|
||||
pytest.raises(DatabaseError, match="database is locked"),
|
||||
|
||||
@@ -1,945 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from asgiref.sync import async_to_sync
|
||||
from asgiref.sync import sync_to_async
|
||||
from celery.contrib.django.task import DjangoTask
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from filelock import Timeout
|
||||
from rest_framework import status
|
||||
|
||||
from documents.file_moves import _active_collector
|
||||
from documents.file_moves import defer_file_moves
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
from documents.file_moves import move_files_for_document
|
||||
from documents.file_moves import request_file_move
|
||||
from documents.models import CustomField
|
||||
from documents.models import Document
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import check_scheduled_workflows
|
||||
from documents.tasks import empty_trash
|
||||
from paperless.celery import app as celery_app
|
||||
from paperless.middleware import FileMoveMiddleware
|
||||
from paperless_testing.factories import CorrespondentFactory
|
||||
from paperless_testing.factories import CustomFieldFactory
|
||||
from paperless_testing.factories import CustomFieldInstanceFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import StoragePathFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.locks import record_media_lock_acquisitions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager
|
||||
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
OnCommitCapture = Callable[..., AbstractContextManager[list[Callable[[], object]]]]
|
||||
|
||||
CONTENT = b"original content"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def title_format(settings: Settings) -> None:
|
||||
settings.FILENAME_FORMAT = "{title}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def document(paperless_dirs: PaperlessDirs, title_format: None) -> Document:
|
||||
"""A document named after its title, with its original on disk."""
|
||||
(paperless_dirs.originals_dir / "original.pdf").write_bytes(CONTENT)
|
||||
return DocumentFactory(
|
||||
title="original",
|
||||
filename="original.pdf",
|
||||
mime_type="application/pdf",
|
||||
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="documents.tests.test_file_moves.rename_then_fail")
|
||||
def rename_then_fail(pk: int, originals_dir: str) -> None:
|
||||
"""Test-only task: rename the document, check nothing moved yet, then fail."""
|
||||
doc = Document.objects.get(pk=pk)
|
||||
doc.title = "renamed"
|
||||
doc.save()
|
||||
assert (Path(originals_dir) / "original.pdf").is_file()
|
||||
raise RuntimeError("task failed after saving")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMoveFilesForDocument:
|
||||
def test_moves_using_fresh_db_state(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose title changed in the DB without any signal
|
||||
WHEN:
|
||||
- move_files_for_document is called with its pk
|
||||
THEN:
|
||||
- The file and the DB filename follow the DB title
|
||||
- The returned instance carries the final filename
|
||||
"""
|
||||
Document.objects.filter(pk=document.pk).update(title="renamed")
|
||||
|
||||
moved = move_files_for_document(document.pk)
|
||||
|
||||
assert moved is not None
|
||||
assert moved.filename == "renamed.pdf"
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
|
||||
document.refresh_from_db()
|
||||
assert document.filename == "renamed.pdf"
|
||||
|
||||
def test_missing_document_returns_none(self, paperless_dirs: PaperlessDirs) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A pk with no document row
|
||||
WHEN:
|
||||
- move_files_for_document is called
|
||||
THEN:
|
||||
- It returns None without raising
|
||||
"""
|
||||
assert move_files_for_document(999_999) is None
|
||||
|
||||
def test_document_without_filename_skips_lock(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
title_format: None,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document still being consumed (no filename yet)
|
||||
WHEN:
|
||||
- move_files_for_document is called
|
||||
THEN:
|
||||
- It returns None, writes nothing, and never takes media.lock
|
||||
"""
|
||||
doc = DocumentFactory(filename=None, mime_type="application/pdf")
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
assert move_files_for_document(doc.pk) is None
|
||||
|
||||
assert acquisitions == []
|
||||
doc.refresh_from_db()
|
||||
assert doc.filename is None
|
||||
|
||||
def test_no_change_does_not_write_modified(
|
||||
self,
|
||||
document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document already at its generated path, modified in the past
|
||||
WHEN:
|
||||
- move_files_for_document is called
|
||||
THEN:
|
||||
- modified is not written
|
||||
"""
|
||||
past = timezone.now() - datetime.timedelta(days=1)
|
||||
Document.objects.filter(pk=document.pk).update(modified=past)
|
||||
|
||||
move_files_for_document(document.pk)
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.modified == past
|
||||
|
||||
def test_trashed_already_moved_document_gets_db_path_fixed(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
title_format: None,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A trashed document whose DB filename is stale
|
||||
- Its file already sits at the generated path (checksum matches)
|
||||
WHEN:
|
||||
- move_files_for_document is called
|
||||
THEN:
|
||||
- The DB filename is updated to where the file is
|
||||
"""
|
||||
doc = DocumentFactory(
|
||||
title="current",
|
||||
filename="stale.pdf",
|
||||
mime_type="application/pdf",
|
||||
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
||||
)
|
||||
assert Document.objects.get(pk=doc.pk).filename == "stale.pdf"
|
||||
doc.delete()
|
||||
(paperless_dirs.originals_dir / "current.pdf").write_bytes(CONTENT)
|
||||
|
||||
move_files_for_document(doc.pk)
|
||||
|
||||
assert Document.global_objects.get(pk=doc.pk).filename == "current.pdf"
|
||||
|
||||
def test_lock_timeout_leaves_consistent_state(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
mocker: MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose title changed in the DB
|
||||
- media.lock cannot be acquired
|
||||
WHEN:
|
||||
- move_files_for_document is called
|
||||
THEN:
|
||||
- An error is logged and None is returned
|
||||
- The lock was requested with the 300 second timeout
|
||||
- The DB filename and the file on disk are unchanged
|
||||
"""
|
||||
Document.objects.filter(pk=document.pk).update(title="renamed")
|
||||
lock = mocker.patch("documents.file_moves.media_lock")
|
||||
lock.return_value.__enter__.side_effect = Timeout("media.lock")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="paperless.handlers"):
|
||||
assert move_files_for_document(document.pk) is None
|
||||
|
||||
assert "Timed out" in caplog.text
|
||||
lock.assert_called_once_with(timeout=300)
|
||||
document.refresh_from_db()
|
||||
assert document.filename == "original.pdf"
|
||||
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRequestFileMoveWithoutScope:
|
||||
def test_moves_immediately_and_syncs_only_file_columns(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- No file move scope is open
|
||||
- A caller instance with an unsaved in-memory change
|
||||
WHEN:
|
||||
- request_file_move is called after the title changed in the DB
|
||||
THEN:
|
||||
- The file moves immediately
|
||||
- The caller's filename is updated
|
||||
- The caller's unsaved change is kept
|
||||
"""
|
||||
Document.objects.filter(pk=document.pk).update(title="renamed")
|
||||
unsaved = CorrespondentFactory()
|
||||
document.correspondent = unsaved
|
||||
|
||||
request_file_move(document)
|
||||
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
assert document.filename == "renamed.pdf"
|
||||
assert document.correspondent == unsaved
|
||||
assert document.title == "original"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReceiver:
|
||||
def test_reverse_m2m_requests_a_move_per_document(
|
||||
self,
|
||||
document: Document,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag
|
||||
WHEN:
|
||||
- A document is added through the tag's reverse relation
|
||||
THEN:
|
||||
- A move is requested for the added document, never the tag
|
||||
"""
|
||||
tag = TagFactory()
|
||||
request = mocker.patch("documents.signals.handlers.request_file_move")
|
||||
|
||||
tag.documents.add(document)
|
||||
|
||||
request.assert_called_once()
|
||||
assert request.call_args.args[0].pk == document.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDeferFileMoves:
|
||||
def test_moves_once_on_exit(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An open file move scope
|
||||
WHEN:
|
||||
- The document is saved with a new title inside the scope
|
||||
THEN:
|
||||
- Nothing moves while the scope is open
|
||||
- On exit the file and DB filename follow the final title
|
||||
"""
|
||||
with defer_file_moves():
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
||||
assert Document.objects.get(pk=document.pk).filename == "original.pdf"
|
||||
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
|
||||
assert Document.objects.get(pk=document.pk).filename == "renamed.pdf"
|
||||
|
||||
def test_nested_scope_defers_to_outer(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A scope nested inside another
|
||||
WHEN:
|
||||
- The document is saved inside the inner scope and the inner scope exits
|
||||
THEN:
|
||||
- Nothing moves until the outer scope exits
|
||||
"""
|
||||
with defer_file_moves():
|
||||
with defer_file_moves():
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
||||
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
def test_closed_collector_moves_immediately(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A collector that has already been closed (work outliving its scope)
|
||||
WHEN:
|
||||
- A save asks for a move while that collector is still current
|
||||
THEN:
|
||||
- The move happens immediately instead of being lost
|
||||
"""
|
||||
with defer_file_moves():
|
||||
collector = _active_collector.get()
|
||||
assert collector is not None
|
||||
token = _active_collector.set(collector)
|
||||
try:
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
finally:
|
||||
_active_collector.reset(token)
|
||||
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
def test_scope_closing_inside_atomic_flushes_on_commit(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
django_capture_on_commit_callbacks: OnCommitCapture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A scope opened and closed inside an atomic block
|
||||
WHEN:
|
||||
- The atomic block commits
|
||||
THEN:
|
||||
- The move runs only once the commit callbacks run
|
||||
"""
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
with transaction.atomic(), defer_file_moves():
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
||||
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
def test_scope_closing_inside_rolled_back_atomic_moves_nothing(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
django_capture_on_commit_callbacks: OnCommitCapture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A scope opened and closed inside an atomic block
|
||||
WHEN:
|
||||
- The atomic block rolls back
|
||||
THEN:
|
||||
- The title change is gone and the file is not moved
|
||||
"""
|
||||
|
||||
class Rollback(Exception):
|
||||
pass
|
||||
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
with pytest.raises(Rollback):
|
||||
with transaction.atomic(), defer_file_moves():
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
raise Rollback
|
||||
|
||||
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
||||
assert not (paperless_dirs.originals_dir / "renamed.pdf").exists()
|
||||
assert Document.objects.get(pk=document.pk).title == "original"
|
||||
|
||||
def test_flush_pending_moves_early(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An open scope with a pending move
|
||||
WHEN:
|
||||
- flush_pending_file_moves is called inside the scope
|
||||
THEN:
|
||||
- The move happens before the scope exits
|
||||
"""
|
||||
with defer_file_moves():
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
flush_pending_file_moves()
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
def test_document_without_filename_in_scope_is_noop(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
title_format: None,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document still being consumed (no filename yet)
|
||||
WHEN:
|
||||
- It is saved inside a scope and the scope exits
|
||||
THEN:
|
||||
- Nothing is written, media.lock is never taken, nothing raises
|
||||
"""
|
||||
doc = DocumentFactory(filename=None, mime_type="application/pdf")
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
with defer_file_moves():
|
||||
doc.title = "renamed"
|
||||
doc.save()
|
||||
|
||||
assert acquisitions == []
|
||||
doc.refresh_from_db()
|
||||
assert doc.filename is None
|
||||
|
||||
def test_mover_keeps_metadata_changed_after_save(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A filename format using correspondent and title
|
||||
- A scope in which the title is saved
|
||||
WHEN:
|
||||
- Another writer changes the correspondent before the scope exits
|
||||
THEN:
|
||||
- The correspondent change survives the flush
|
||||
- The final path reflects both changes
|
||||
"""
|
||||
settings.FILENAME_FORMAT = "{correspondent}/{title}"
|
||||
correspondent = CorrespondentFactory(name="acme")
|
||||
|
||||
with defer_file_moves():
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
Document.objects.filter(pk=document.pk).update(correspondent=correspondent)
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.correspondent == correspondent
|
||||
assert document.filename == "acme/renamed.pdf"
|
||||
assert (paperless_dirs.originals_dir / "acme" / "renamed.pdf").is_file()
|
||||
|
||||
def test_one_failure_does_not_stop_the_flush(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
title_format: None,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents saved in one scope
|
||||
- Moving the first raises an unexpected error
|
||||
WHEN:
|
||||
- The scope exits
|
||||
THEN:
|
||||
- The scope does not raise
|
||||
- The second document is still moved
|
||||
"""
|
||||
(paperless_dirs.originals_dir / "a.pdf").write_bytes(CONTENT)
|
||||
first = DocumentFactory(
|
||||
filename="a.pdf",
|
||||
title="a",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
(paperless_dirs.originals_dir / "b.pdf").write_bytes(CONTENT)
|
||||
second = DocumentFactory(
|
||||
filename="b.pdf",
|
||||
title="b",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
real_move = move_files_for_document
|
||||
|
||||
def failing_for_first(pk: int) -> Document | None:
|
||||
if pk == first.pk:
|
||||
raise RuntimeError("boom")
|
||||
return real_move(pk)
|
||||
|
||||
mocker.patch(
|
||||
"documents.file_moves.move_files_for_document",
|
||||
side_effect=failing_for_first,
|
||||
)
|
||||
|
||||
with defer_file_moves():
|
||||
first.title = "a2"
|
||||
first.save()
|
||||
second.title = "b2"
|
||||
second.save()
|
||||
|
||||
assert (paperless_dirs.originals_dir / "b2.pdf").is_file()
|
||||
assert (paperless_dirs.originals_dir / "a.pdf").is_file()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFileMoveMiddleware:
|
||||
def test_sync_mode_flushes_before_returning(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The middleware wrapping a sync view that renames the document
|
||||
WHEN:
|
||||
- A request passes through it
|
||||
THEN:
|
||||
- Nothing moves while the view runs
|
||||
- The file is at its final path when the middleware returns
|
||||
"""
|
||||
|
||||
def view(request: HttpRequest) -> HttpResponse:
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
||||
return HttpResponse()
|
||||
|
||||
middleware = FileMoveMiddleware(view)
|
||||
assert middleware.async_mode is False
|
||||
|
||||
middleware(RequestFactory().get("/"))
|
||||
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
def test_async_mode_flushes_after_await(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The middleware wrapping an async view that saves through
|
||||
sync_to_async
|
||||
WHEN:
|
||||
- A request passes through it
|
||||
THEN:
|
||||
- Nothing moves while the view runs
|
||||
- The file is at its final path after the await, so the collector
|
||||
survived the context copy into the sync thread
|
||||
"""
|
||||
|
||||
def rename() -> None:
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
|
||||
async def view(request: HttpRequest) -> HttpResponse:
|
||||
await sync_to_async(rename)()
|
||||
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
|
||||
return HttpResponse()
|
||||
|
||||
middleware = FileMoveMiddleware(view)
|
||||
assert middleware.async_mode is True
|
||||
|
||||
async_to_sync(middleware)(RequestFactory().get("/"))
|
||||
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
def test_view_raising_after_save_still_moves(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A view that saves a rename and then raises
|
||||
WHEN:
|
||||
- A request passes through the middleware
|
||||
THEN:
|
||||
- The exception propagates
|
||||
- The committed rename is still moved
|
||||
"""
|
||||
|
||||
def view(request: HttpRequest) -> HttpResponse:
|
||||
document.title = "renamed"
|
||||
document.save()
|
||||
raise RuntimeError("view failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="view failed"):
|
||||
FileMoveMiddleware(view)(RequestFactory().get("/"))
|
||||
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFileMovesThroughApi:
|
||||
def test_media_lock_never_taken_inside_a_transaction(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document and a tag
|
||||
WHEN:
|
||||
- A tag + title PATCH and a note POST are made through the API
|
||||
THEN:
|
||||
- media.lock is acquired at least once
|
||||
- media.lock is never first acquired while a transaction is open
|
||||
"""
|
||||
tag = TagFactory()
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
response = admin_client.patch(
|
||||
f"/api/documents/{document.pk}/",
|
||||
{"tags": [tag.pk], "title": "renamed"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response = admin_client.post(
|
||||
f"/api/documents/{document.pk}/notes/",
|
||||
{"note": "a note"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
assert acquisitions
|
||||
assert not any(a.in_user_transaction for a in acquisitions)
|
||||
|
||||
def test_patch_moves_to_final_path(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document named after its title
|
||||
WHEN:
|
||||
- Its title and tags are changed through the API
|
||||
THEN:
|
||||
- The file is at the final path when the response returns
|
||||
"""
|
||||
response = admin_client.patch(
|
||||
f"/api/documents/{document.pk}/",
|
||||
{"tags": [TagFactory().pk], "title": "renamed"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
assert Document.objects.get(pk=document.pk).filename == "renamed.pdf"
|
||||
|
||||
def test_patch_response_reports_post_move_modified(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document named after its title
|
||||
WHEN:
|
||||
- Its title is changed through the API, causing a rename
|
||||
THEN:
|
||||
- The modified time in the response equals the stored value,
|
||||
which the file move bumped after the edit was saved
|
||||
"""
|
||||
response = admin_client.patch(
|
||||
f"/api/documents/{document.pk}/",
|
||||
{"title": "renamed"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
stored = Document.objects.get(pk=document.pk)
|
||||
assert stored.filename == "renamed.pdf"
|
||||
assert parse_datetime(response.json()["modified"]) == stored.modified
|
||||
|
||||
def test_trash_restore_keeps_db_and_disk_consistent(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
document: Document,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document moved to the trash
|
||||
WHEN:
|
||||
- It is restored through the trash API
|
||||
THEN:
|
||||
- Its DB filename points at a file that exists
|
||||
"""
|
||||
admin_client.delete(f"/api/documents/{document.pk}/")
|
||||
|
||||
response = admin_client.post(
|
||||
"/api/trash/",
|
||||
{"action": "restore", "documents": [document.pk]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
restored = Document.objects.get(pk=document.pk)
|
||||
assert restored.source_path.is_file()
|
||||
|
||||
def test_root_storage_path_change_moves_versions(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A root document with one version, both with files on disk
|
||||
WHEN:
|
||||
- The root's storage path is changed through the API
|
||||
THEN:
|
||||
- The root and version files both follow the storage path
|
||||
"""
|
||||
(paperless_dirs.originals_dir / "version.pdf").write_bytes(CONTENT)
|
||||
version = DocumentFactory(
|
||||
root_document=document,
|
||||
title="version",
|
||||
filename="version.pdf",
|
||||
mime_type="application/pdf",
|
||||
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
||||
)
|
||||
storage_path = StoragePathFactory(path="stored/{{ title }}")
|
||||
|
||||
response = admin_client.patch(
|
||||
f"/api/documents/{document.pk}/",
|
||||
{"storage_path": storage_path.pk},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
document.refresh_from_db()
|
||||
version.refresh_from_db()
|
||||
assert document.filename.startswith("stored/")
|
||||
assert version.filename.startswith("stored/")
|
||||
assert document.source_path.is_file()
|
||||
assert version.source_path.is_file()
|
||||
|
||||
def test_custom_field_change_moves_to_final_path(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A filename format that uses a text custom field
|
||||
- A document with that field set
|
||||
WHEN:
|
||||
- The field value is changed through the API
|
||||
THEN:
|
||||
- The file is at the path for the new value
|
||||
"""
|
||||
settings.FILENAME_FORMAT = (
|
||||
"{{ custom_fields|get_cf_value('flavor') }}/{{ title }}"
|
||||
)
|
||||
field = CustomFieldFactory(
|
||||
name="flavor",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
CustomFieldInstanceFactory(field=field, document=document, value_text="apple")
|
||||
|
||||
response = admin_client.patch(
|
||||
f"/api/documents/{document.pk}/",
|
||||
{"custom_fields": [{"field": field.pk, "value": "cherry"}]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
document.refresh_from_db()
|
||||
assert document.filename == "cherry/original.pdf"
|
||||
assert (paperless_dirs.originals_dir / "cherry" / "original.pdf").is_file()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCeleryTaskScope:
|
||||
def test_task_class_opens_scope(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The paperless Celery app
|
||||
WHEN:
|
||||
- A registered task is inspected
|
||||
THEN:
|
||||
- It runs through FileMoveScopedTask
|
||||
- It keeps Celery's DjangoTask API, such as delay_on_commit
|
||||
"""
|
||||
from paperless.celery import FileMoveScopedTask
|
||||
|
||||
assert isinstance(bulk_update_documents, FileMoveScopedTask)
|
||||
assert isinstance(bulk_update_documents, DjangoTask)
|
||||
|
||||
def test_failing_task_still_moves_committed_change(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A task that renames a document, then raises
|
||||
WHEN:
|
||||
- The task is applied
|
||||
THEN:
|
||||
- The task fails with its own error
|
||||
- Nothing moved while the task body ran
|
||||
- The committed rename is moved when the task ends
|
||||
"""
|
||||
result = rename_then_fail.apply(
|
||||
args=[document.pk, str(paperless_dirs.originals_dir)],
|
||||
)
|
||||
|
||||
assert result.failed()
|
||||
assert isinstance(result.result, RuntimeError)
|
||||
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
|
||||
|
||||
def test_bulk_update_moves_each_document(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents whose titles changed in the DB without signals
|
||||
WHEN:
|
||||
- bulk_update_documents runs for both
|
||||
THEN:
|
||||
- Each document's file follows its new title
|
||||
"""
|
||||
mocker.patch("documents.search.get_backend")
|
||||
(paperless_dirs.originals_dir / "second.pdf").write_bytes(CONTENT)
|
||||
second = DocumentFactory(
|
||||
title="second",
|
||||
filename="second.pdf",
|
||||
mime_type="application/pdf",
|
||||
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
||||
)
|
||||
Document.objects.filter(pk=document.pk).update(title="first-renamed")
|
||||
Document.objects.filter(pk=second.pk).update(title="second-renamed")
|
||||
|
||||
bulk_update_documents.apply(args=[[document.pk, second.pk]])
|
||||
|
||||
assert (paperless_dirs.originals_dir / "first-renamed.pdf").is_file()
|
||||
assert (paperless_dirs.originals_dir / "second-renamed.pdf").is_file()
|
||||
|
||||
def test_scheduled_workflows_move_each_document(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents matched by a scheduled workflow, where running the
|
||||
workflow renames the document
|
||||
WHEN:
|
||||
- check_scheduled_workflows runs
|
||||
THEN:
|
||||
- The first document's file is already moved when the workflow
|
||||
runs for the second, not only when the task ends
|
||||
"""
|
||||
(paperless_dirs.originals_dir / "second.pdf").write_bytes(CONTENT)
|
||||
second = DocumentFactory(
|
||||
title="second",
|
||||
filename="second.pdf",
|
||||
mime_type="application/pdf",
|
||||
checksum=hashlib.sha256(CONTENT).hexdigest(),
|
||||
)
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||
schedule_offset_days=0,
|
||||
schedule_date_field=WorkflowTrigger.ScheduleDateField.ADDED,
|
||||
)
|
||||
workflow = Workflow.objects.create(name="scheduled", order=0)
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(WorkflowAction.objects.create(assign_title="unused"))
|
||||
|
||||
seen: dict[int, bool] = {}
|
||||
|
||||
def rename(*, document: Document, **kwargs: object) -> None:
|
||||
seen[document.pk] = (paperless_dirs.originals_dir / "first.pdf").is_file()
|
||||
document.title = "first" if document.pk == first_pk else "other"
|
||||
document.save()
|
||||
|
||||
first_pk = min(document.pk, second.pk)
|
||||
mocker.patch("documents.tasks.run_workflows", side_effect=rename)
|
||||
mocker.patch("documents.tasks.send_websocket_document_updated")
|
||||
|
||||
check_scheduled_workflows.apply()
|
||||
|
||||
second_pk = max(document.pk, second.pk)
|
||||
assert seen[first_pk] is False
|
||||
assert seen[second_pk] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEmptyTrashLockOrdering:
|
||||
def test_hard_delete_takes_media_lock_outside_transaction(
|
||||
self,
|
||||
document: Document,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A trashed document with its file on disk
|
||||
WHEN:
|
||||
- The trash is emptied for it
|
||||
THEN:
|
||||
- media.lock is never first acquired inside a transaction
|
||||
- The document row and its file are gone
|
||||
"""
|
||||
document.delete()
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
empty_trash([document.pk])
|
||||
|
||||
assert acquisitions
|
||||
assert not any(a.in_user_transaction for a in acquisitions)
|
||||
assert not Document.global_objects.filter(pk=document.pk).exists()
|
||||
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
|
||||
@@ -18,13 +18,11 @@ if TYPE_CHECKING:
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.file_moves import move_files_for_document
|
||||
from documents.models import Document
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.locks import record_media_lock_acquisitions
|
||||
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
|
||||
|
||||
sample_file: Path = SIMPLE_DIGITAL_PDF
|
||||
@@ -65,28 +63,6 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertTrue(filecmp.cmp(sample_file, doc.source_path))
|
||||
self.assertEqual(doc.archive_filename, "none/A.pdf")
|
||||
|
||||
def test_handle_document_takes_media_lock_outside_transaction(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose archive version is being generated
|
||||
WHEN:
|
||||
- update_document_content_maybe_archive_file runs
|
||||
THEN:
|
||||
- media.lock is acquired
|
||||
- media.lock is never first acquired while a transaction holds the
|
||||
document row
|
||||
"""
|
||||
doc = self.make_models()
|
||||
shutil.copy(sample_file, Path(self.dirs.originals_dir) / f"{doc.id:07}.pdf")
|
||||
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
update_document_content_maybe_archive_file(doc.pk)
|
||||
|
||||
self.assertTrue(acquisitions)
|
||||
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
|
||||
doc.refresh_from_db()
|
||||
self.assertIsFile(doc.archive_path)
|
||||
|
||||
def test_unknown_mime_type(self) -> None:
|
||||
doc = self.make_models()
|
||||
doc.mime_type = "sdgfh"
|
||||
@@ -260,7 +236,7 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
doc = Document.objects.create(title="test", mime_type="image/jpeg")
|
||||
doc.filename = generate_filename(doc)
|
||||
doc.archive_filename = generate_filename(doc, archive_filename=True)
|
||||
doc.save(write_file_paths=True)
|
||||
doc.save()
|
||||
|
||||
Path(doc.source_path).touch()
|
||||
Path(doc.archive_path).touch()
|
||||
@@ -277,42 +253,6 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
self.assertIsFile(doc2.source_path)
|
||||
self.assertIsFile(doc2.archive_path)
|
||||
|
||||
@override_settings(FILENAME_FORMAT="")
|
||||
def test_rename_moves_each_document_by_direct_call(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents whose filename format changed
|
||||
WHEN:
|
||||
- document_renamer runs
|
||||
THEN:
|
||||
- move_files_for_document is called once per document
|
||||
- Both files are at their new paths
|
||||
"""
|
||||
docs = []
|
||||
for title in ("first", "second"):
|
||||
doc = Document.objects.create(title=title, mime_type="image/jpeg")
|
||||
doc.filename = generate_filename(doc)
|
||||
doc.save(write_file_paths=True)
|
||||
Path(doc.source_path).touch()
|
||||
docs.append(doc)
|
||||
|
||||
with (
|
||||
override_settings(FILENAME_FORMAT="{title}"),
|
||||
mock.patch(
|
||||
"documents.management.commands.document_renamer.move_files_for_document",
|
||||
wraps=move_files_for_document,
|
||||
) as mover,
|
||||
):
|
||||
call_command("document_renamer", skip_checks=True)
|
||||
|
||||
self.assertCountEqual(
|
||||
[c.args[0] for c in mover.call_args_list],
|
||||
[d.pk for d in docs],
|
||||
)
|
||||
for doc in docs:
|
||||
doc.refresh_from_db()
|
||||
self.assertIsFile(doc.source_path)
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
class TestCreateClassifier:
|
||||
|
||||
@@ -4,13 +4,10 @@ Tests for the document_retagger management command.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
from documents.file_moves import _active_collector
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
@@ -24,12 +21,6 @@ from paperless_testing.factories import DocumentTypeFactory
|
||||
from paperless_testing.factories import StoragePathFactory
|
||||
from paperless_testing.factories import TagFactory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level type aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -493,69 +484,3 @@ class TestRetaggerEdgeCases(DirectoriesMixin):
|
||||
|
||||
assert d_first.tags.count() == 0
|
||||
assert d_unrelated.tags.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
class TestRetaggerFileMoves:
|
||||
def test_each_document_is_processed_in_its_own_scope(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents and a filename format using the correspondent
|
||||
- A classifier suggestion assigning a correspondent to each
|
||||
WHEN:
|
||||
- document_retagger runs with --correspondent
|
||||
THEN:
|
||||
- Each document is processed inside an open file move scope
|
||||
- When the second document is processed, the first one's file has
|
||||
already moved
|
||||
- Both files end at their final paths
|
||||
"""
|
||||
settings.FILENAME_FORMAT = "{correspondent}/{title}"
|
||||
(paperless_dirs.originals_dir / "none").mkdir(exist_ok=True)
|
||||
for title in ("first", "second"):
|
||||
(paperless_dirs.originals_dir / "none" / f"{title}.pdf").write_bytes(b"x")
|
||||
DocumentFactory(
|
||||
title=title,
|
||||
filename=f"none/{title}.pdf",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
correspondent = CorrespondentFactory(name="acme")
|
||||
processed: list[Document] = []
|
||||
scope_open: list[bool] = []
|
||||
seen_first_moved: list[bool] = []
|
||||
|
||||
def fake_set_correspondent(sender, document, **kwargs):
|
||||
scope_open.append(_active_collector.get() is not None)
|
||||
# Order-agnostic: on the second call, check the first one's file.
|
||||
if processed:
|
||||
first = processed[0]
|
||||
seen_first_moved.append(
|
||||
(
|
||||
paperless_dirs.originals_dir / "acme" / f"{first.title}.pdf"
|
||||
).is_file(),
|
||||
)
|
||||
processed.append(document)
|
||||
document.correspondent = correspondent
|
||||
document.save(update_fields=("correspondent",))
|
||||
return correspondent
|
||||
|
||||
mocker.patch(
|
||||
"documents.management.commands.document_retagger.load_classifier",
|
||||
)
|
||||
mocker.patch(
|
||||
"documents.management.commands.document_retagger.set_correspondent",
|
||||
side_effect=fake_set_correspondent,
|
||||
)
|
||||
|
||||
call_command("document_retagger", "--correspondent", skip_checks=True)
|
||||
|
||||
assert scope_open == [True, True]
|
||||
assert seen_first_moved == [True]
|
||||
for title in ("first", "second"):
|
||||
assert (paperless_dirs.originals_dir / "acme" / f"{title}.pdf").is_file()
|
||||
@@ -1,121 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.db import transaction
|
||||
from filelock import Timeout
|
||||
|
||||
from documents.file_moves import in_user_transaction
|
||||
from documents.media_lock import media_lock
|
||||
from paperless_testing.locks import record_media_lock_acquisitions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
class TestMediaLock:
|
||||
def test_same_thread_reenters_without_blocking(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- media.lock held by the current thread
|
||||
WHEN:
|
||||
- The same thread acquires it again with a zero timeout
|
||||
THEN:
|
||||
- The nested acquisition succeeds immediately
|
||||
"""
|
||||
with media_lock(), media_lock(timeout=0):
|
||||
pass
|
||||
|
||||
def test_other_thread_is_excluded(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- media.lock held by the current thread
|
||||
WHEN:
|
||||
- Another thread tries to acquire it with a short timeout
|
||||
THEN:
|
||||
- The other thread times out
|
||||
- After release, the other thread can acquire it
|
||||
"""
|
||||
outcomes: list[str] = []
|
||||
|
||||
def try_acquire() -> None:
|
||||
try:
|
||||
with media_lock(timeout=0.2):
|
||||
outcomes.append("acquired")
|
||||
except Timeout:
|
||||
outcomes.append("timeout")
|
||||
|
||||
with media_lock():
|
||||
worker = threading.Thread(target=try_acquire)
|
||||
worker.start()
|
||||
worker.join()
|
||||
|
||||
worker = threading.Thread(target=try_acquire)
|
||||
worker.start()
|
||||
worker.join()
|
||||
|
||||
assert outcomes == ["timeout", "acquired"]
|
||||
|
||||
def test_lock_file_is_the_configured_path(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The test's media directory layout
|
||||
WHEN:
|
||||
- media.lock is acquired
|
||||
THEN:
|
||||
- The lock file is created at settings.MEDIA_LOCK
|
||||
"""
|
||||
with media_lock():
|
||||
assert paperless_dirs.media_lock.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestInUserTransaction:
|
||||
def test_false_inside_test_case_wrapper(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The transaction pytest-django wraps around the test
|
||||
WHEN:
|
||||
- in_user_transaction is called
|
||||
THEN:
|
||||
- It returns False
|
||||
"""
|
||||
assert in_user_transaction() is False
|
||||
|
||||
def test_true_inside_atomic(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An atomic block opened by code under test
|
||||
WHEN:
|
||||
- in_user_transaction is called inside it
|
||||
THEN:
|
||||
- It returns True
|
||||
"""
|
||||
with transaction.atomic():
|
||||
assert in_user_transaction() is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
class TestRecordMediaLockAcquisitions:
|
||||
def test_records_only_first_acquisitions(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Nested media.lock acquisitions, the outer one inside a transaction
|
||||
WHEN:
|
||||
- They are recorded
|
||||
THEN:
|
||||
- Only the outer, first acquisition is recorded, flagged as inside
|
||||
a transaction
|
||||
"""
|
||||
with record_media_lock_acquisitions() as acquisitions:
|
||||
with transaction.atomic(), media_lock(), media_lock():
|
||||
pass
|
||||
|
||||
assert [a.in_user_transaction for a in acquisitions] == [True]
|
||||
@@ -1,7 +1,4 @@
|
||||
import pytest
|
||||
from django.db import DatabaseError
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
@@ -25,101 +22,3 @@ class TestDocument:
|
||||
assert Correspondent.objects.count() == 0
|
||||
assert Document.objects.count() == 1
|
||||
assert Document.objects.first().correspondent is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("paperless_dirs")
|
||||
class TestDocumentFilePathWrites:
|
||||
def test_full_save_does_not_write_file_paths(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose filename columns were changed in the DB after
|
||||
an instance was loaded
|
||||
WHEN:
|
||||
- The stale instance is saved without update_fields
|
||||
THEN:
|
||||
- The DB filename columns keep the newer values
|
||||
- Other fields from the stale instance are written
|
||||
"""
|
||||
doc = DocumentFactory(filename="old.pdf", archive_filename="old.pdf")
|
||||
stale = Document.objects.get(pk=doc.pk)
|
||||
Document.objects.filter(pk=doc.pk).update(
|
||||
filename="new.pdf",
|
||||
archive_filename="new-archive.pdf",
|
||||
)
|
||||
|
||||
stale.title = "changed"
|
||||
stale.save()
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert doc.filename == "new.pdf"
|
||||
assert doc.archive_filename == "new-archive.pdf"
|
||||
assert doc.title == "changed"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"save_kwargs",
|
||||
[
|
||||
pytest.param({"write_file_paths": True}, id="write-file-paths"),
|
||||
pytest.param(
|
||||
{"update_fields": ["filename", "archive_filename"]},
|
||||
id="explicit-update-fields",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_opt_in_writes_file_paths(self, save_kwargs: dict[str, object]) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An existing document
|
||||
WHEN:
|
||||
- Its filename columns are changed in memory and saved with an
|
||||
explicit opt in
|
||||
THEN:
|
||||
- The DB filename columns are updated
|
||||
"""
|
||||
doc = DocumentFactory(filename="old.pdf", archive_filename="old.pdf")
|
||||
|
||||
doc.filename = "new.pdf"
|
||||
doc.archive_filename = "new.pdf"
|
||||
doc.save(**save_kwargs)
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert doc.filename == "new.pdf"
|
||||
assert doc.archive_filename == "new.pdf"
|
||||
|
||||
def test_creation_writes_file_paths(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A new, unsaved document with filename columns set
|
||||
WHEN:
|
||||
- It is saved
|
||||
THEN:
|
||||
- The filename columns are written
|
||||
"""
|
||||
doc = DocumentFactory.build(filename="a.pdf", archive_filename="b.pdf")
|
||||
|
||||
doc.save()
|
||||
|
||||
doc.refresh_from_db()
|
||||
assert doc.filename == "a.pdf"
|
||||
assert doc.archive_filename == "b.pdf"
|
||||
|
||||
def test_full_save_of_deleted_row_raises(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An instance whose row was hard-deleted elsewhere
|
||||
WHEN:
|
||||
- The instance is saved without update_fields
|
||||
THEN:
|
||||
- A DatabaseError is raised instead of re-inserting the row
|
||||
"""
|
||||
doc = DocumentFactory()
|
||||
stale = Document.objects.get(pk=doc.pk)
|
||||
# A plain QuerySet bypasses the soft-delete manager, so the row is
|
||||
# really gone (as after emptying the trash).
|
||||
models.QuerySet(model=Document).filter(pk=doc.pk).delete()
|
||||
|
||||
# The savepoint keeps the failed UPDATE from poisoning the test transaction.
|
||||
with pytest.raises(DatabaseError), transaction.atomic():
|
||||
stale.save()
|
||||
|
||||
assert not Document.global_objects.filter(pk=doc.pk).exists()
|
||||
@@ -132,7 +132,7 @@ class TestCheckSanityOriginal:
|
||||
class TestCheckSanityArchive:
|
||||
def test_checksum_without_filename(self, sample_doc: Document) -> None:
|
||||
sample_doc.archive_filename = None
|
||||
sample_doc.save(write_file_paths=True)
|
||||
sample_doc.save()
|
||||
messages = check_sanity()
|
||||
assert messages.has_error
|
||||
assert any(
|
||||
@@ -185,7 +185,7 @@ class TestCheckSanityArchive:
|
||||
Path(sample_doc.archive_path).unlink()
|
||||
sample_doc.archive_checksum = None
|
||||
sample_doc.archive_filename = None
|
||||
sample_doc.save(write_file_paths=True)
|
||||
sample_doc.save()
|
||||
messages = check_sanity()
|
||||
assert not messages.has_error
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
|
||||
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
|
||||
if archive:
|
||||
self.document.archive_filename = f"{self.document.pk:07}.pdf"
|
||||
self.document.save(write_file_paths=True)
|
||||
self.document.save()
|
||||
path = self.document.archive_path
|
||||
assert path is not None
|
||||
else:
|
||||
|
||||
+35
-124
@@ -11,6 +11,7 @@ from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from inspect import signature
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
from time import mktime
|
||||
@@ -106,6 +107,9 @@ from documents import bulk_edit
|
||||
from documents.bulk_download import ArchiveOnlyStrategy
|
||||
from documents.bulk_download import OriginalAndArchiveStrategy
|
||||
from documents.bulk_download import OriginalsOnlyStrategy
|
||||
from documents.bulk_operations import QUEUED_OPERATIONS
|
||||
from documents.bulk_operations import execute_bulk_operation
|
||||
from documents.bulk_operations import snapshot_field
|
||||
from documents.caching import get_llm_suggestion_cache
|
||||
from documents.caching import get_metadata_cache
|
||||
from documents.caching import get_suggestion_cache
|
||||
@@ -128,7 +132,6 @@ from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.file_handling import format_filename
|
||||
from documents.file_moves import flush_pending_file_moves
|
||||
from documents.filters import CorrespondentFilterSet
|
||||
from documents.filters import CustomFieldFilterSet
|
||||
from documents.filters import DocumentFilterSet
|
||||
@@ -174,6 +177,7 @@ from documents.permissions import ViewDocumentsPermissions
|
||||
from documents.permissions import annotate_document_count_for_related_queryset
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_permission_grantees
|
||||
from documents.permissions import has_document_operation_permissions
|
||||
from documents.permissions import has_global_statistics_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import has_system_status_permission
|
||||
@@ -1292,8 +1296,6 @@ class DocumentViewSet(
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
# The response must reflect the post-move filename and modified time
|
||||
flush_pending_file_moves()
|
||||
|
||||
if content_updated and content_doc.id != root_doc.id:
|
||||
content_doc.content = (
|
||||
@@ -2985,98 +2987,29 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
"merge_as_versions",
|
||||
}
|
||||
|
||||
def _has_document_permissions(
|
||||
self,
|
||||
*,
|
||||
user: User,
|
||||
documents: list[int],
|
||||
method,
|
||||
parameters: dict[str, Any],
|
||||
) -> bool:
|
||||
if user.is_superuser:
|
||||
return True
|
||||
def _has_document_permissions(self, **kwargs) -> bool:
|
||||
return has_document_operation_permissions(**kwargs)
|
||||
|
||||
root_docs = {
|
||||
get_root_document(doc)
|
||||
for doc in Document.objects.select_related(
|
||||
"owner",
|
||||
"root_document__owner",
|
||||
).filter(pk__in=documents)
|
||||
}
|
||||
user_is_owner_of_all_documents = all(
|
||||
(doc.owner == user or doc.owner is None) for doc in root_docs
|
||||
def _queue_bulk_operation(self, method, documents, parameters, from_webui):
|
||||
if "update_callback" in parameters:
|
||||
raise ValueError("Unsupported bulk operation parameter: update_callback")
|
||||
signature(method).bind(documents, **parameters)
|
||||
task = execute_bulk_operation.apply_async(
|
||||
kwargs={
|
||||
"operation": method.__name__,
|
||||
"document_ids": documents,
|
||||
"parameters": parameters,
|
||||
"user_id": self.request.user.pk,
|
||||
},
|
||||
headers={
|
||||
"trigger_source": (
|
||||
PaperlessTask.TriggerSource.WEB_UI
|
||||
if from_webui
|
||||
else PaperlessTask.TriggerSource.API_UPLOAD
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# check global and object permissions for all documents
|
||||
has_perms = (
|
||||
user.has_perm(
|
||||
"documents.change_document",
|
||||
)
|
||||
and not Document.global_objects.filter(pk__in=documents)
|
||||
.exclude(pk__in=permitted_document_ids(user, perm="change_document"))
|
||||
.exists()
|
||||
)
|
||||
|
||||
# check ownership for methods that change original document
|
||||
if (
|
||||
(
|
||||
has_perms
|
||||
and method
|
||||
in [
|
||||
bulk_edit.set_permissions,
|
||||
bulk_edit.delete,
|
||||
bulk_edit.rotate,
|
||||
bulk_edit.delete_pages,
|
||||
bulk_edit.edit_pdf,
|
||||
bulk_edit.merge_as_versions,
|
||||
bulk_edit.remove_password,
|
||||
]
|
||||
)
|
||||
or (
|
||||
method in [bulk_edit.merge, bulk_edit.split]
|
||||
and parameters.get("delete_originals")
|
||||
)
|
||||
or (method == bulk_edit.edit_pdf and parameters.get("update_document"))
|
||||
):
|
||||
has_perms = has_perms and user_is_owner_of_all_documents
|
||||
|
||||
# check global add permissions for methods that create documents
|
||||
if (
|
||||
has_perms
|
||||
and (
|
||||
method in [bulk_edit.split, bulk_edit.merge]
|
||||
or (
|
||||
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
||||
and not parameters.get("update_document")
|
||||
)
|
||||
)
|
||||
and not user.has_perm("documents.add_document")
|
||||
):
|
||||
has_perms = False
|
||||
|
||||
# check global delete permissions for methods that delete documents
|
||||
if (
|
||||
has_perms
|
||||
and (
|
||||
method == bulk_edit.delete
|
||||
# Sources stop being documents of their own, and removing one
|
||||
# again afterwards needs delete_document
|
||||
or method == bulk_edit.merge_as_versions
|
||||
or (
|
||||
method in [bulk_edit.merge, bulk_edit.split]
|
||||
and parameters.get("delete_originals")
|
||||
)
|
||||
or (
|
||||
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
|
||||
and parameters.get("delete_original")
|
||||
and not parameters.get("update_document")
|
||||
)
|
||||
)
|
||||
and not user.has_perm("documents.delete_document")
|
||||
):
|
||||
has_perms = False
|
||||
|
||||
return has_perms
|
||||
return Response({"result": "OK", "task_id": task.id})
|
||||
|
||||
def _execute_document_action(
|
||||
self,
|
||||
@@ -3144,6 +3077,7 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
name="BulkEditDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(required=False),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -3173,37 +3107,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
||||
|
||||
serializer_class = BulkEditSerializer
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
|
||||
"""
|
||||
Returns each document's current value of field, for the audit log.
|
||||
|
||||
Tags and custom fields are one row per value, so they are gathered
|
||||
into a sorted list of pks per document (empty when there are none).
|
||||
Reading them through Document.values() instead would join those rows
|
||||
and return one arbitrary value per document.
|
||||
"""
|
||||
if field == "tags":
|
||||
rows = (
|
||||
Document.tags.through.objects.filter(document_id__in=doc_ids)
|
||||
.order_by("tag_id")
|
||||
.values_list("document_id", "tag_id")
|
||||
)
|
||||
elif field == "custom_fields":
|
||||
rows = (
|
||||
CustomFieldInstance.objects.filter(document_id__in=doc_ids)
|
||||
.order_by("pk")
|
||||
.values_list("document_id", "pk")
|
||||
)
|
||||
else:
|
||||
return dict(
|
||||
Document.objects.filter(pk__in=doc_ids).values_list("pk", field),
|
||||
)
|
||||
|
||||
values: dict[int, list[int]] = {doc_id: [] for doc_id in doc_ids}
|
||||
for doc_id, pk in rows:
|
||||
values[doc_id].append(pk)
|
||||
return values
|
||||
_snapshot_field = staticmethod(snapshot_field)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
request_method = request.data.get("method")
|
||||
@@ -3249,6 +3153,13 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
try:
|
||||
if method.__name__ in QUEUED_OPERATIONS:
|
||||
return self._queue_bulk_operation(
|
||||
method,
|
||||
documents,
|
||||
parameters,
|
||||
from_webui,
|
||||
)
|
||||
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
|
||||
if settings.AUDIT_LOG_ENABLED and modified_field:
|
||||
old_values = self._snapshot_field(documents, modified_field)
|
||||
|
||||
+1
-23
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.contrib.django.task import DjangoTask
|
||||
from celery.signals import worker_process_init
|
||||
from kombu.serialization import register
|
||||
|
||||
@@ -27,28 +26,7 @@ register(
|
||||
content_encoding="binary",
|
||||
)
|
||||
|
||||
|
||||
class FileMoveScopedTask(DjangoTask):
|
||||
"""
|
||||
Run every task inside a document file move scope, so each document a task
|
||||
changes is moved once when the task ends, after its writes have committed.
|
||||
The scope also flushes when the task raises, for writes that committed
|
||||
before the failure.
|
||||
|
||||
Subclasses DjangoTask because a custom task_cls replaces the Django fixup's
|
||||
default, and delay_on_commit lives there.
|
||||
"""
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
# Function-local: this module is imported by paperless/__init__.py
|
||||
# before Django apps are loaded.
|
||||
from documents.file_moves import defer_file_moves
|
||||
|
||||
with defer_file_moves():
|
||||
return super().__call__(*args, **kwargs)
|
||||
|
||||
|
||||
app = Celery("paperless", task_cls=FileMoveScopedTask)
|
||||
app = Celery("paperless")
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
|
||||
@@ -45,12 +45,21 @@ class StatusConsumer(AsyncWebsocketConsumer):
|
||||
await self.close()
|
||||
return
|
||||
await self.channel_layer.group_add("status_updates", self.channel_name)
|
||||
await self.channel_layer.group_add(
|
||||
f"bulk_operations_{self.scope['user'].id}",
|
||||
self.channel_name,
|
||||
)
|
||||
await self.accept()
|
||||
self._start_heartbeat()
|
||||
|
||||
async def disconnect(self, code: int) -> None:
|
||||
await self._stop_heartbeat()
|
||||
await self.channel_layer.group_discard("status_updates", self.channel_name)
|
||||
if self._authenticated():
|
||||
await self.channel_layer.group_discard(
|
||||
f"bulk_operations_{self.scope['user'].id}",
|
||||
self.channel_name,
|
||||
)
|
||||
|
||||
def _start_heartbeat(self) -> None:
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
@@ -84,3 +93,9 @@ class StatusConsumer(AsyncWebsocketConsumer):
|
||||
await self.close()
|
||||
elif await self._can_view(event["data"]):
|
||||
await self.send(json.dumps(event))
|
||||
|
||||
async def bulk_operation_status(self, event: dict) -> None:
|
||||
if not self._authenticated():
|
||||
await self.close()
|
||||
else:
|
||||
await self.send(json.dumps(event))
|
||||
@@ -1,5 +1,3 @@
|
||||
from asgiref.sync import iscoroutinefunction
|
||||
from asgiref.sync import markcoroutinefunction
|
||||
from compression_middleware.middleware import CompressionMiddleware
|
||||
from django.conf import settings
|
||||
|
||||
@@ -32,35 +30,3 @@ class ApiVersionMiddleware:
|
||||
response["X-Version"] = version.__full_version_str__
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class FileMoveMiddleware:
|
||||
"""
|
||||
Open a document file move scope around each request, so every document the
|
||||
request changes is moved once, after its writes have committed, before the
|
||||
response is returned.
|
||||
"""
|
||||
|
||||
sync_capable = True
|
||||
async_capable = True
|
||||
|
||||
def __init__(self, get_response) -> None:
|
||||
self.get_response = get_response
|
||||
self.async_mode = iscoroutinefunction(get_response)
|
||||
if self.async_mode:
|
||||
markcoroutinefunction(self)
|
||||
|
||||
def __call__(self, request):
|
||||
if self.async_mode:
|
||||
return self.__acall__(request)
|
||||
|
||||
from documents.file_moves import defer_file_moves
|
||||
|
||||
with defer_file_moves():
|
||||
return self.get_response(request)
|
||||
|
||||
async def __acall__(self, request):
|
||||
from documents.file_moves import adefer_file_moves
|
||||
|
||||
async with adefer_file_moves():
|
||||
return await self.get_response(request)
|
||||
@@ -188,7 +188,6 @@ if DEBUG:
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"paperless.middleware.FileMoveMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
@@ -691,7 +690,7 @@ logging.config.dictConfig(LOGGING)
|
||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
|
||||
|
||||
# Tasks outside tasks.py must be loaded even when Django skips checks
|
||||
CELERY_IMPORTS = ("documents.bulk_edit",)
|
||||
CELERY_IMPORTS = ("documents.bulk_edit", "documents.bulk_operations")
|
||||
|
||||
CELERY_BROKER_URL = _CELERY_REDIS_URL
|
||||
CELERY_RESULT_BACKEND = _CELERY_REDIS_URL
|
||||
|
||||
@@ -22,8 +22,8 @@ logger = logging.getLogger("paperless_ai.chat")
|
||||
CHAT_METADATA_DELIMITER = "\n\n__PAPERLESS_CHAT_METADATA__"
|
||||
CHAT_ERROR_MESSAGE = "Sorry, something went wrong while generating a response."
|
||||
CHAT_NO_CONTENT_MESSAGE = "Sorry, I couldn't find any content to answer your question."
|
||||
MAX_CHAT_REFERENCES = 3
|
||||
CHAT_RETRIEVER_TOP_K = 5
|
||||
MAX_CHAT_REFERENCES = CHAT_RETRIEVER_TOP_K
|
||||
|
||||
|
||||
def _build_chat_prompt(output_language: str | None) -> str:
|
||||
|
||||
@@ -8,8 +8,6 @@ import factory
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentBarcode
|
||||
from documents.models import DocumentType
|
||||
@@ -72,22 +70,6 @@ class DocumentFactory(TypedModelFactory[Document]):
|
||||
storage_path = None
|
||||
|
||||
|
||||
class CustomFieldFactory(TypedModelFactory[CustomField]):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
|
||||
name = factory.Sequence(lambda n: f"field {n}")
|
||||
data_type = CustomField.FieldDataType.STRING
|
||||
|
||||
|
||||
class CustomFieldInstanceFactory(TypedModelFactory[CustomFieldInstance]):
|
||||
class Meta:
|
||||
model = CustomFieldInstance
|
||||
|
||||
field = factory.SubFactory(CustomFieldFactory)
|
||||
document = factory.SubFactory(DocumentFactory)
|
||||
|
||||
|
||||
class DocumentBarcodeFactory(TypedModelFactory[DocumentBarcode]):
|
||||
class Meta:
|
||||
model = DocumentBarcode
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Record first acquisitions of the global media lock during a test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MediaLockAcquisition:
|
||||
"""One first (non-reentrant) acquisition of settings.MEDIA_LOCK."""
|
||||
|
||||
in_user_transaction: bool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def record_media_lock_acquisitions() -> Iterator[list[MediaLockAcquisition]]:
|
||||
"""
|
||||
Record every first acquisition of settings.MEDIA_LOCK made in the block.
|
||||
|
||||
Reentrant acquisitions by a thread that already holds the lock never wait,
|
||||
so they are not recorded. Each record notes whether a real (non test case)
|
||||
transaction was open at that moment, which is the lock ordering hazard.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from filelock import BaseFileLock
|
||||
|
||||
from documents.file_moves import in_user_transaction
|
||||
|
||||
records: list[MediaLockAcquisition] = []
|
||||
original_acquire = BaseFileLock.acquire
|
||||
|
||||
def recording_acquire(self: BaseFileLock, *args: object, **kwargs: object):
|
||||
if Path(self.lock_file) == Path(settings.MEDIA_LOCK) and not self.is_locked:
|
||||
records.append(
|
||||
MediaLockAcquisition(in_user_transaction=in_user_transaction()),
|
||||
)
|
||||
return original_acquire(self, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(BaseFileLock, "acquire", recording_acquire):
|
||||
yield records
|
||||
Reference in new issue
Block a user