Compare commits

..
41 changed files with 382 additions and 1532 deletions
-1
View File
@@ -227,7 +227,6 @@ Version-aware endpoints:
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided.
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
-4
View File
@@ -99,10 +99,6 @@ Think of versions as **file history** for a document.
- By default, search and document content use the latest version.
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
- From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own.
- From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document.
- Documents merged as versions give up their archive serial number. If the root has no ASN of its own it takes the first one, otherwise the ASNs are released and the removal is logged.
- Merging as versions cannot be undone from the UI, and deleting the root document moves its versions to the trash as well.
### Management Lists
+71 -22
View File
@@ -5973,7 +5973,7 @@
<source>Open <x id="PH" equiv-text="this.title"/> filter</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">828</context>
<context context-type="linenumber">831</context>
</context-group>
</trans-unit>
<trans-unit id="7005745151564974365" datatype="html">
@@ -6382,27 +6382,6 @@
<context context-type="linenumber">94</context>
</context-group>
</trans-unit>
<trans-unit id="5947558132119506443" datatype="html">
<source>My documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">25,26</context>
</context-group>
</trans-unit>
<trans-unit id="231920238966427751" datatype="html">
<source>Shared with me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">35,36</context>
</context-group>
</trans-unit>
<trans-unit id="175385209536581523" datatype="html">
<source>Shared by me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
<context context-type="linenumber">45,46</context>
</context-group>
</trans-unit>
<trans-unit id="5151074932731293042" datatype="html">
<source>Unowned</source>
<context-group purpose="location">
@@ -6417,6 +6396,76 @@
<context context-type="linenumber">85</context>
</context-group>
</trans-unit>
<trans-unit id="5947558132119506443" datatype="html">
<source>My documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">101</context>
</context-group>
</trans-unit>
<trans-unit id="1930869169119109336" datatype="html">
<source>Owned by <x id="PH" equiv-text="username"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="5339682692608120628" datatype="html">
<source>Owned by another user</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">107</context>
</context-group>
</trans-unit>
<trans-unit id="231920238966427751" datatype="html">
<source>Shared with me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">117</context>
</context-group>
</trans-unit>
<trans-unit id="1894556100995563325" datatype="html">
<source>Not owned by <x id="PH" equiv-text="usernames.join(&apos;, &apos;)"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">124</context>
</context-group>
</trans-unit>
<trans-unit id="4647949080250052038" datatype="html">
<source>Not owned by another user</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">127</context>
</context-group>
</trans-unit>
<trans-unit id="8858352775080403297" datatype="html">
<source>Not owned by selected users</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">128</context>
</context-group>
</trans-unit>
<trans-unit id="175385209536581523" datatype="html">
<source>Shared by me</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">136</context>
</context-group>
</trans-unit>
<trans-unit id="5140574576358170412" datatype="html">
<source>Shared by <x id="PH" equiv-text="username"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">141</context>
</context-group>
</trans-unit>
<trans-unit id="391557549689505150" datatype="html">
<source>Shared by another user</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
<context context-type="linenumber">142</context>
</context-group>
</trans-unit>
<trans-unit id="941924371433275463" datatype="html">
<source>Global permissions define what areas of the app and API endpoints users can access.</source>
<context-group purpose="location">
@@ -1,58 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="form-group">
<label class="form-label" for="rootDocumentID" i18n>Root document:</label>
<select id="rootDocumentID" class="form-select" [ngModel]="rootDocumentID()" (ngModelChange)="rootDocumentID.set($event)">
@for (document of documents(); track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
}
</select>
</div>
<div class="form-group mt-4">
<span class="form-label d-inline-block" i18n>Versions (oldest first):</span>
<ul class="list-group"
cdkDropList
[cdkDropListData]="versionDocumentIDs()"
(cdkDropListDropped)="onDrop($event)">
@for (documentID of versionDocumentIDs(); track documentID) {
@let document = getDocument(documentID);
@if (document) {
<li class="list-group-item d-flex align-items-center" cdkDrag>
<i-bs name="grip-vertical" class="me-2"></i-bs>
<div class="d-flex flex-column">
<div>
@if (document.correspondent) {
<b>{{document.correspondent | correspondentName | async}}: </b>
}{{document.title}}
</div>
<small class="text-muted">
{{document.created | customDate:'mediumDate'}}
@if (document.page_count) {
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
}
</small>
</div>
@if ($last) {
<span class="badge bg-primary ms-auto" i18n>Current version</span>
}
</li>
}
}
</ul>
@if (versionDocumentIDs().length > 1) {
<div class="form-text" i18n>Drag to reorder.</div>
}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
{{btnCaption}}
</button>
</div>
@@ -1,70 +0,0 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
describe('MergeAsVersionsConfirmDialogComponent', () => {
let component: MergeAsVersionsConfirmDialogComponent
let fixture: ComponentFixture<MergeAsVersionsConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NgxBootstrapIconsModule.pick(allIcons),
MergeAsVersionsConfirmDialogComponent,
],
providers: [
NgbActiveModal,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(MergeAsVersionsConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should fetch selected documents', () => {
const documents = [
{ id: 1, title: 'Document 1' },
{ id: 2, title: 'Document 2' },
]
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: [1, 2],
count: 2,
results: documents,
})
)
component.documentIDs.set([1, 2])
component.ngOnInit()
expect(component.documents()).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith([1, 2])
})
it('should exclude the root from the draggable documents', () => {
component.documentIDs.set([1, 2, 3])
component.rootDocumentID.set(2)
expect(component.versionDocumentIDs()).toEqual([1, 3])
})
it('should move draggable documents while keeping the root fixed', () => {
component.documentIDs.set([1, 2, 3])
component.rootDocumentID.set(1)
component.onDrop({ previousIndex: 1, currentIndex: 0 } as any)
expect(component.documentIDs()).toEqual([1, 3, 2])
expect(component.versionDocumentIDs()).toEqual([3, 2])
})
})
@@ -1,70 +0,0 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { AsyncPipe } from '@angular/common'
import { Component, OnInit, computed, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@Component({
selector: 'pngx-merge-as-versions-confirm-dialog',
templateUrl: './merge-as-versions-confirm-dialog.component.html',
styleUrl: './merge-as-versions-confirm-dialog.component.scss',
imports: [
AsyncPipe,
CorrespondentNamePipe,
CustomDatePipe,
DragDropModule,
FormsModule,
NgxBootstrapIconsModule,
],
})
export class MergeAsVersionsConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
private readonly documentService = inject(DocumentService)
readonly documentIDs = signal<number[]>([])
readonly documents = signal<Document[]>([])
readonly rootDocumentID = signal(-1)
readonly versionDocumentIDs = computed(() =>
this.documentIDs().filter(
(documentID) => documentID !== this.rootDocumentID()
)
)
ngOnInit() {
this.documentService
.getFew(this.documentIDs())
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((response) => this.documents.set(response.results))
}
onDrop(event: CdkDragDrop<number[]>) {
const versionDocumentIDs = this.versionDocumentIDs().concat()
moveItemInArray(versionDocumentIDs, event.previousIndex, event.currentIndex)
// The root keeps its place in the list, only the versions move around it
let versionIndex = 0
this.documentIDs.update((documentIDs) =>
documentIDs.map((documentID) =>
documentID === this.rootDocumentID()
? documentID
: versionDocumentIDs[versionIndex++]
)
)
}
getDocument(documentID: number): Document | undefined {
return this.documents().find((document) => document.id === documentID)
}
}
@@ -36,7 +36,7 @@
</div>
<div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
<select id="metadataDocumentID" class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
@for (document of documents(); track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
@@ -1,4 +1,4 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" placement="bottom-end" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
</button>
@@ -6,5 +6,16 @@
&.show {
margin-left: -245px !important;
}
.list-group {
max-height: min(50dvh, 20rem);
overflow-y: auto;
}
.list-group-item:first-child {
position: sticky;
top: 0;
z-index: 1;
}
}
}
@@ -49,7 +49,7 @@
</cdk-virtual-scroll-viewport>
}
@if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined) {
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
@@ -62,7 +62,7 @@
</button>
}
}
@if (extraButtonTitle) {
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
@@ -911,6 +911,25 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(createSpy).toHaveBeenCalled()
})
it('should only show create when a non-empty filter has no matches', () => {
component.selectionModel.items = []
component.icon = 'tag-fill'
component.editing = true
component.createRef = jest.fn()
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Create')
component.listFilterEnter()
expect(component.createRef).not.toHaveBeenCalled()
const filterInput: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="text"]')
filterInput.value = 'FooBar'
filterInput.dispatchEvent(new Event('input'))
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
})
it('should exclude item and trigger change event', () => {
const id = 1
const state = ToggleableItemState.Selected
@@ -970,4 +989,18 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(extraButtonClicked).toBeTruthy()
expect(applied).toBeFalsy()
})
it('should only show the extra button for an empty result when enabled', () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.extraButtonTitle = 'Extra'
component.filterText = 'FooBar'
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Extra')
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Extra')
})
})
@@ -774,6 +774,9 @@ export class FilterableDropdownComponent
@Input()
extraButtonTitle: string
@Input()
showExtraButtonIfEmpty: boolean = false
creating: boolean = false
@Output()
@@ -892,7 +895,11 @@ export class FilterableDropdownComponent
this.dropdown.close()
}
}, 200)
} else if (filtered.length == 0 && this.createRef) {
} else if (
filtered.length == 0 &&
this.createRef &&
this.filterText?.length > 0
) {
this.createClicked()
}
}
@@ -22,7 +22,7 @@
}
</div>
<div class="me-1">
<small i18n>My documents</small>
<small>{{ownerFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
@@ -32,7 +32,7 @@
}
</div>
<div class="me-1">
<small i18n>Shared with me</small>
<small>{{ownerExclusionFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
@@ -42,7 +42,7 @@
}
</div>
<div class="me-1">
<small i18n>Shared by me</small>
<small>{{sharedByFilterLabel}}</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
@@ -94,6 +94,58 @@ describe('PermissionsFilterDropdownComponent', () => {
expect(component.isActive).toBeTruthy()
})
it('should describe concrete user filters honestly', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 1
expect(component.ownerFilterLabel).toEqual('Owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [1]
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 1
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
})
it('should describe concrete filters when usernames are unavailable', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 99
expect(component.ownerFilterLabel).toEqual('Owned by another user')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by another user'
)
component.selectionModel.excludeUsers = [98, 99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by selected users'
)
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 99
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
})
it('should retain relative labels for filters bound to the current user', () => {
component.selectionModel.userID = currentUserID
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
component.selectionModel.excludeUsers = [currentUserID]
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
})
it('should retain relative labels for inactive filter choices', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
})
it('should support reset', () => {
component.setFilter(OwnerFilterType.OTHERS)
expect(component.selectionModel.ownerFilter).not.toEqual(
@@ -93,6 +93,55 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
)
}
get ownerFilterLabel(): string {
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`My documents`
}
const username = this.getUsername(this.selectionModel?.userID)
return username
? $localize`Owned by ${username}`
: $localize`Owned by another user`
}
get ownerExclusionFilterLabel(): string {
const excludedUsers = this.selectionModel?.excludeUsers ?? []
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
(excludedUsers.length === 1 &&
excludedUsers[0] === this.settingsService.currentUser()?.id)
) {
return $localize`Shared with me`
}
const usernames = excludedUsers
.map((id) => this.getUsername(id))
.filter(Boolean)
if (usernames.length === excludedUsers.length && usernames.length > 0) {
return $localize`Not owned by ${usernames.join(', ')}`
}
return excludedUsers.length === 1
? $localize`Not owned by another user`
: $localize`Not owned by selected users`
}
get sharedByFilterLabel(): string {
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`Shared by me`
}
const username = this.getUsername(this.selectionModel?.userID)
return username
? $localize`Shared by ${username}`
: $localize`Shared by another user`
}
constructor() {
const userService = inject(UserService)
@@ -164,4 +213,8 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
}
this.onChange()
}
private getUsername(userID: number): string {
return this.users().find((user) => user.id === userID)?.username
}
}
@@ -467,13 +467,6 @@ describe('DocumentDetailComponent', () => {
const docWithVersions = {
...doc,
versions: [
{
id: 10,
added: new Date('2024-01-02T00:00:00Z'),
version_label: 'Edited',
checksum: 'bbbb',
is_root: false,
},
{
id: doc.id,
added: new Date('2024-01-01T00:00:00Z'),
@@ -481,6 +474,13 @@ describe('DocumentDetailComponent', () => {
checksum: 'aaaa',
is_root: true,
},
{
id: 10,
added: new Date('2024-01-02T00:00:00Z'),
version_label: 'Edited',
checksum: 'bbbb',
is_root: false,
},
],
} as Document
@@ -1232,8 +1232,8 @@ describe('DocumentDetailComponent', () => {
metadataSpy.mockClear()
component.document().versions = [
{ id: 10, is_root: false },
{ id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
@@ -1929,8 +1929,8 @@ describe('DocumentDetailComponent', () => {
component.documentId.set(doc.id)
component.document.set({ ...doc, versions: [] } as Document)
const updatedVersions = [
{ id: 10, is_root: false },
{ id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any
const openDoc = { ...doc, versions: [] } as Document
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
@@ -2046,8 +2046,8 @@ describe('DocumentDetailComponent', () => {
it('should include version in download and print only for non-latest selected version', () => {
initNormally()
component.document().versions = [
{ id: 10, is_root: false },
{ id: doc.id, is_root: true },
{ id: 10, is_root: false },
] as any
const getDownloadUrlSpy = jest
@@ -889,9 +889,13 @@ export class DocumentDetailComponent
updateComponent(doc: Document) {
this.document.set(doc)
// Default selected version is the newest version, which the API returns first
// Default selected version is the newest version
const versions = doc.versions ?? []
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
this.selectedVersionId.set(
versions.length
? Math.max(...versions.map((version) => version.id))
: doc.id
)
this.previewLoaded.set(false)
this.requiresPassword = false
this.updateFormForCustomFields()
@@ -1437,8 +1441,7 @@ export class DocumentDetailComponent
if (!versions.length || !this.selectedVersionId()) {
return null
}
// The API returns versions newest first
const latestVersionId = versions[0].id
const latestVersionId = Math.max(...versions.map((version) => version.id))
return this.selectedVersionId() === latestVersionId
? null
: this.selectedVersionId()
@@ -1,18 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" i18n>Add existing document as version</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
<pngx-input-document-link
[(ngModel)]="selectedDocumentIDs"
[parentDocumentID]="rootDocumentID"
[minimal]="true"
placeholder="Search for a document"
i18n-placeholder
></pngx-input-document-link>
<div class="form-text mt-2" i18n>Select one document to add as a version.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled()" i18n>Cancel</button>
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled() || selectedDocumentIDs.length !== 1" i18n>Add version</button>
</div>
@@ -1,73 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog.component'
describe('AddExistingDocumentVersionDialogComponent', () => {
let component: AddExistingDocumentVersionDialogComponent
let fixture: ComponentFixture<AddExistingDocumentVersionDialogComponent>
let activeModal: jest.Mocked<Pick<NgbActiveModal, 'dismiss'>>
beforeEach(async () => {
activeModal = { dismiss: jest.fn() }
await TestBed.configureTestingModule({
imports: [AddExistingDocumentVersionDialogComponent],
providers: [
{
provide: NgbActiveModal,
useValue: activeModal,
},
{
provide: DocumentService,
useValue: {},
},
],
}).compileComponents()
fixture = TestBed.createComponent(AddExistingDocumentVersionDialogComponent)
component = fixture.componentInstance
component.rootDocumentID = 3
fixture.detectChanges()
})
it('should emit the single selected document', () => {
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
component.selectedDocumentIDs = [20]
component.confirm()
expect(emitSpy).toHaveBeenCalledWith(20)
})
it('should require exactly one selected document', () => {
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
component.selectedDocumentIDs = [20, 21]
component.confirm()
expect(emitSpy).not.toHaveBeenCalled()
})
it('should dismiss on cancel', () => {
component.cancel()
expect(activeModal.dismiss).toHaveBeenCalled()
})
it('should re-render the buttons when they are toggled from outside', async () => {
const cancelButton: HTMLButtonElement = fixture.nativeElement.querySelector(
'.modal-footer button'
)
expect(cancelButton.disabled).toBeFalsy()
// No detectChanges: the dropdown toggling this from a request callback is
// all that happens, and nothing else schedules a render for the modal
component.buttonsEnabled.set(false)
await fixture.whenStable()
expect(cancelButton.disabled).toBeTruthy()
component.buttonsEnabled.set(true)
await fixture.whenStable()
expect(cancelButton.disabled).toBeFalsy()
})
})
@@ -1,35 +0,0 @@
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
@Component({
selector: 'pngx-add-existing-document-version-dialog',
templateUrl: './add-existing-document-version-dialog.component.html',
imports: [DocumentLinkComponent, FormsModule],
})
export class AddExistingDocumentVersionDialogComponent {
private readonly activeModal = inject(NgbActiveModal)
@Input() rootDocumentID: number
@Output() confirmClicked = new EventEmitter<number>()
selectedDocumentIDs: number[] = []
readonly buttonsEnabled = signal(true)
confirm(): void {
if (this.selectedDocumentIDs.length !== 1) return
this.confirmClicked.emit(this.selectedDocumentIDs[0])
}
cancel(): void {
this.activeModal.dismiss()
}
}
@@ -24,26 +24,13 @@
class="visually-hidden"
(change)="onVersionFileSelected($event)"
/>
<div class="btn-group btn-group-sm w-100">
<button
class="btn btn-sm btn-outline-secondary w-100"
(click)="versionFileInput.click()"
[disabled]="!userIsOwner || !userCanEdit"
title="Upload a new version"
i18n-title
>
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Upload</span>
</button>
<button
class="btn btn-sm btn-outline-secondary w-100"
(click)="addExistingDocumentAsVersion()"
[disabled]="!userIsOwner || !userCanEdit"
title="Use an existing document"
i18n-title
>
<i-bs name="file-earmark"></i-bs><span class="ps-1" i18n>Existing</span>
</button>
</div>
<button
class="btn btn-sm btn-outline-secondary w-100"
(click)="versionFileInput.click()"
[disabled]="!userIsOwner || !userCanEdit"
>
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
</button>
} @else {
@switch (versionUploadState()) {
@case (UploadState.Uploading) {
@@ -1,7 +1,6 @@
import { DatePipe } from '@angular/common'
import { SimpleChange, signal } from '@angular/core'
import { SimpleChange } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
import { DocumentVersionInfo } from 'src/app/data/document'
@@ -20,17 +19,12 @@ describe('DocumentVersionDropdownComponent', () => {
let documentService: jest.Mocked<
Pick<
DocumentService,
| 'deleteVersion'
| 'getVersions'
| 'mergeDocumentsAsVersions'
| 'uploadVersion'
| 'updateVersionLabel'
'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
>
>
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
let finished$: Subject<{ taskId: string }>
let failed$: Subject<{ taskId: string; message?: string }>
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
beforeEach(async () => {
finished$ = new Subject<{ taskId: string }>()
@@ -38,11 +32,9 @@ describe('DocumentVersionDropdownComponent', () => {
documentService = {
deleteVersion: jest.fn(),
getVersions: jest.fn(),
mergeDocumentsAsVersions: jest.fn(),
uploadVersion: jest.fn(),
updateVersionLabel: jest.fn(),
}
modalService = { open: jest.fn() }
toastService = {
showError: jest.fn(),
showInfo: jest.fn(),
@@ -69,10 +61,6 @@ describe('DocumentVersionDropdownComponent', () => {
provide: ToastService,
useValue: toastService,
},
{
provide: NgbModal,
useValue: modalService,
},
{
provide: WebsocketStatusService,
useValue: {
@@ -234,10 +222,9 @@ describe('DocumentVersionDropdownComponent', () => {
})
it('onVersionFileSelected should upload and update versions after websocket success', () => {
// Newest first, as the API returns them
const versions: DocumentVersionInfo[] = [
{ id: 20, is_root: false, checksum: 'cccc' },
{ id: 3, is_root: true, checksum: 'aaaa' },
{ id: 20, is_root: false, checksum: 'cccc' },
]
const file = new File(['test'], 'new-version.pdf', {
type: 'application/pdf',
@@ -336,45 +323,4 @@ describe('DocumentVersionDropdownComponent', () => {
expect(component.editingVersionId).toBeNull()
expect(component.versionLabelDraft).toEqual('')
})
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
const confirmClicked = new Subject<number>()
const modal = {
componentInstance: {
rootDocumentID: null,
buttonsEnabled: signal(true),
confirmClicked,
},
close: jest.fn(),
}
modalService.open.mockReturnValue(modal as any)
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
// Newest first, as the API returns them. The merged document has a lower id
// than the root, which is the whole point of merging an existing document.
const versions: DocumentVersionInfo[] = [
{ id: 2, is_root: false, checksum: 'cccc' },
{ id: 3, is_root: true, checksum: 'aaaa' },
]
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
component.newVersionLabel = ' Imported '
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
component.addExistingDocumentAsVersion()
expect(modal.componentInstance.rootDocumentID).toEqual(3)
confirmClicked.next(2)
expect(documentService.mergeDocumentsAsVersions).toHaveBeenCalledWith(
[3, 2],
3,
'Imported'
)
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
expect(documentService.getVersions).toHaveBeenCalledWith(3)
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
expect(component.newVersionLabel).toEqual('')
expect(modal.close).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalled()
})
})
@@ -11,7 +11,7 @@ import {
SimpleChanges,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { merge, of, Subject } from 'rxjs'
import {
@@ -33,7 +33,6 @@ import {
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
@Component({
selector: 'pngx-document-version-dropdown',
@@ -70,7 +69,6 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
private readonly documentsService = inject(DocumentService)
private readonly toastService = inject(ToastService)
private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly modalService = inject(NgbModal)
private readonly destroy$ = new Subject<void>()
private readonly documentChange$ = new Subject<void>()
@@ -258,10 +256,11 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
.subscribe({
next: (doc) => {
if (uploadDocumentId !== this.documentId) return
if (doc?.versions?.length) {
if (doc?.versions) {
this.versionsUpdated.emit(doc.versions)
// The API returns versions newest first
this.versionSelected.emit(doc.versions[0].id)
this.versionSelected.emit(
Math.max(...doc.versions.map((version) => version.id))
)
this.clearVersionUploadStatus()
}
},
@@ -279,55 +278,6 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
})
}
addExistingDocumentAsVersion(): void {
const modal = this.modalService.open(
AddExistingDocumentVersionDialogComponent,
{ backdrop: 'static' }
)
const dialog =
modal.componentInstance as AddExistingDocumentVersionDialogComponent
dialog.rootDocumentID = this.documentId
dialog.confirmClicked
.pipe(takeUntil(this.destroy$), takeUntil(this.documentChange$))
.subscribe((existingDocumentID) => {
dialog.buttonsEnabled.set(false)
const versionLabel = this.newVersionLabel?.trim()
this.documentsService
.mergeDocumentsAsVersions(
[this.documentId, existingDocumentID],
this.documentId,
versionLabel
)
.pipe(
switchMap(() => this.documentsService.getVersions(this.documentId)),
first(),
finalize(() => dialog.buttonsEnabled.set(true)),
takeUntil(this.destroy$),
takeUntil(this.documentChange$)
)
.subscribe({
next: (document) => {
if (document?.versions?.length) {
this.versionsUpdated.emit(document.versions)
// The API returns versions newest first
this.versionSelected.emit(document.versions[0].id)
}
this.newVersionLabel = ''
modal.close()
this.toastService.showInfo(
$localize`Existing document added as a version.`
)
},
error: (error) => {
this.toastService.showError(
$localize`Error adding existing document as a version`,
error
)
},
})
})
}
clearVersionUploadStatus(): void {
this.versionUploadState.set(UploadState.Idle)
this.versionUploadError.set(null)
@@ -95,9 +95,6 @@
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
</button>
</div>
</div>
</div>
@@ -1248,89 +1248,6 @@ describe('BulkEditorComponent', () => {
expect(documentListViewService.selected.size).toEqual(0)
})
it('should support merging documents as versions', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: [3, 4],
count: 2,
results: [
{ id: 3, title: 'Document 3' },
{ id: 4, title: 'Document 4' },
],
})
)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
jest
.spyOn(permissionsService, 'currentUserOwnsObject')
.mockReturnValue(true)
const mergeAsVersionsSpy = jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(of(true))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
fixture.detectChanges()
component.mergeSelectedAsVersions()
expect(modal).not.toBeUndefined()
modal.componentInstance.rootDocumentID.set(4)
modal.componentInstance.confirm()
expect(mergeAsVersionsSpy).toHaveBeenCalledWith([3, 4], 4)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
)
expect(documentListViewService.selected.size).toEqual(0)
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.')
})
it('should not report success when merging documents as versions fails', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: [3, 4],
count: 2,
results: [
{ id: 3, title: 'Document 3' },
{ id: 4, title: 'Document 4' },
],
})
)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(documentService, 'mergeDocumentsAsVersions')
.mockReturnValue(throwError(() => new Error('failed')))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
fixture.detectChanges()
component.mergeSelectedAsVersions()
modal.componentInstance.rootDocumentID.set(4)
modal.componentInstance.confirm()
expect(toastErrorSpy).toHaveBeenCalled()
expect(toastInfoSpy).not.toHaveBeenCalled()
})
it('should support bulk download with archive, originals or both and file formatting', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
@@ -50,7 +50,6 @@ import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -172,13 +171,6 @@ export class BulkEditorComponent
)
}
get userCanDelete(): boolean {
return this.permissionService.currentUserCan(
PermissionAction.Delete,
PermissionType.Document
)
}
ngOnInit() {
if (
this.permissionService.currentUserCan(
@@ -295,17 +287,14 @@ export class BulkEditorComponent
private executeDocumentAction(
modal: NgbModalRef,
request: Observable<any>,
options: { clearSelection?: boolean; successMessage?: string } = {}
options: { deleteOriginals?: boolean } = {}
) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(false)
}
request.pipe(first()).subscribe({
next: () => {
this.handleOperationSuccess(modal, options.clearSelection ?? false)
if (options.successMessage) {
this.toastService.showInfo(options.successMessage)
}
this.handleOperationSuccess(modal, options.deleteOriginals ?? false)
},
error: (error) => this.handleOperationError(modal, error),
})
@@ -996,7 +985,7 @@ export class BulkEditorComponent
this.executeDocumentAction(
modal,
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
{ clearSelection: !!args.delete_originals }
{ deleteOriginals: !!args.delete_originals }
)
this.toastService.showInfo(
$localize`Merged document will be queued for consumption.`
@@ -1004,35 +993,6 @@ export class BulkEditorComponent
})
}
mergeSelectedAsVersions() {
let modal = this.modalService.open(MergeAsVersionsConfirmDialogComponent, {
backdrop: 'static',
})
const mergeDialog =
modal.componentInstance as MergeAsVersionsConfirmDialogComponent
const documentIDs = Array.from(this.list.selected)
mergeDialog.title = $localize`Merge as versions`
mergeDialog.message = $localize`The selected documents will become versions of the root document.`
mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs.set(documentIDs)
mergeDialog.rootDocumentID.set(documentIDs[0])
mergeDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.executeDocumentAction(
modal,
this.documentService.mergeDocumentsAsVersions(
mergeDialog.documentIDs(),
mergeDialog.rootDocumentID()
),
{
clearSelection: true,
successMessage: $localize`Documents merged as versions.`,
}
)
})
}
public setCustomFieldValues(changedCustomFields: ChangedItems) {
const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
backdrop: 'static',
@@ -316,34 +316,6 @@ describe(`DocumentService`, () => {
})
})
it('should call appropriate api endpoint for merging documents as versions', () => {
const ids = [1, 2, 3]
subscription = service.mergeDocumentsAsVersions(ids, 2).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/merge_as_versions/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
documents: ids,
root_document_id: 2,
})
})
it('should include an optional label when merging one document as a version', () => {
const ids = [1, 2]
subscription = service
.mergeDocumentsAsVersions(ids, 2, 'Imported')
.subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/merge_as_versions/`
)
expect(req.request.body).toEqual({
documents: ids,
root_document_id: 2,
version_label: 'Imported',
})
})
it('should call appropriate api endpoint for edit pdf', () => {
const ids = [1]
const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] }
@@ -374,18 +374,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
})
}
mergeDocumentsAsVersions(
ids: number[],
rootDocumentId: number,
versionLabel?: string
) {
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
documents: ids,
root_document_id: rootDocumentId,
...(versionLabel ? { version_label: versionLabel } : {}),
})
}
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
documents: ids,
-2
View File
@@ -115,7 +115,6 @@ import {
house,
inbox,
infoCircle,
journalBookmarkFill,
journals,
link,
listNested,
@@ -362,7 +361,6 @@ const icons = {
house,
inbox,
infoCircle,
journalBookmarkFill,
journals,
link,
listNested,
-112
View File
@@ -12,7 +12,6 @@ from celery import group
from celery import shared_task
from django.conf import settings
from django.db import transaction
from django.db.models import Max
from django.db.models import Q
from django.utils import timezone
@@ -31,7 +30,6 @@ from documents.permissions import set_permissions_for_object
from documents.plugins.helpers import DocumentsStatusManager
from documents.tasks import bulk_update_documents
from documents.tasks import consume_file
from documents.tasks import remove_document_from_index
from documents.tasks import update_document_content_maybe_archive_file
from documents.versioning import get_latest_version_for_root
from documents.versioning import get_root_document
@@ -41,9 +39,6 @@ if TYPE_CHECKING:
from django.contrib.auth.models import User
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
SourceMode = Literal["latest_version", "explicit_selection"]
@@ -617,113 +612,6 @@ def merge(
return "OK"
def merge_as_versions(
doc_ids: list[int],
*,
root_document_id: int,
version_label: str | None = None,
user: User | None = None,
) -> Literal["OK"]:
with transaction.atomic():
documents = list(
Document.objects.select_for_update()
.filter(id__in=doc_ids)
.defer("content"),
)
documents_by_id = {document.id: document for document in documents}
source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id]
root_document = documents_by_id[root_document_id]
next_version_index = (
Document.global_objects.filter(
root_document_id=root_document_id,
).aggregate(max_index=Max("version_index"))["max_index"]
or 0
)
# A version gives up its ASN
source_asns = [
documents_by_id[source_id].archive_serial_number
for source_id in source_ids
if documents_by_id[source_id].archive_serial_number is not None
]
updated_fields = ["root_document", "version_index", "archive_serial_number"]
if version_label is not None:
updated_fields.append("version_label")
for source_id in source_ids:
next_version_index += 1
source_document = documents_by_id[source_id]
source_document.root_document_id = root_document.pk
source_document.version_index = next_version_index
source_document.archive_serial_number = None
if version_label is not None:
source_document.version_label = version_label
# bulk_update and not save() to avoid post_save now
Document.objects.bulk_update(
[documents_by_id[source_id] for source_id in source_ids],
updated_fields,
)
root_updates = {"modified": timezone.now()}
if source_asns and root_document.archive_serial_number is None:
# If a version had one, hand the ASN over, the same as merge() does
root_updates["archive_serial_number"] = source_asns.pop(0)
logger.info(
f"Document {root_document.id} took archive serial number "
f"{root_updates['archive_serial_number']} from a document merged into it",
)
if source_asns:
logger.warning(
f"Archive serial number(s) {source_asns} were removed by merging "
f"those documents as versions of document {root_document.id}",
)
Document.objects.filter(pk=root_document.pk).update(**root_updates)
if settings.AUDIT_LOG_ENABLED:
# update() doesn't fire auditlog signals, so manual
LogEntry.objects.log_create(
instance=root_document,
changes={"Merged As Versions": ["None", source_ids]},
action=LogEntry.Action.UPDATE,
actor=user,
additional_data={
"reason": "Merged as versions",
"version_ids": source_ids,
},
)
# One batch rather than a task each
from documents.search import SearchIndexLockError
from documents.search import get_backend
try:
with get_backend().batch_update() as batch:
for source_id in source_ids:
batch.remove(source_id)
except SearchIndexLockError:
logger.error(
f"Search index lock exhausted removing {source_ids}, "
f"scheduling deferred index removal",
)
for source_id in source_ids:
remove_document_from_index.apply_async(args=[source_id], countdown=60)
bulk_update_documents.apply_async(
kwargs={"document_ids": [root_document_id]},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
# And as far as the frontend is concerned, they're deleted
status_mgr = DocumentsStatusManager()
status_mgr.send_documents_deleted(source_ids)
return "OK"
def split(
doc_ids: list[int],
pages: list[list[int]],
+4 -6
View File
@@ -372,10 +372,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
For version documents, this is always the document's own content.
If the queryset already annotated ``effective_content``, that value is used.
"""
# Here to avoid circular import
from documents.versioning import sort_versions_newest_first
from documents.versioning import versions_newest_first
if hasattr(self, "effective_content"):
return getattr(self, "effective_content")
@@ -392,10 +388,12 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
# Empty list means prefetch ran and found no versions — use own content.
if not prefetched_versions:
return self.content
return sort_versions_newest_first(prefetched_versions)[0].content
latest_prefetched = max(prefetched_versions, key=lambda doc: doc.id)
return latest_prefetched.content
latest_version_content = (
versions_newest_first(Document.objects.filter(root_document=self))
Document.objects.filter(root_document=self)
.order_by("-id")
.values_list("content", flat=True)
.first()
)
+21 -1
View File
@@ -223,7 +223,27 @@ class WriteBatch:
)
time.sleep(sleep_s)
self._raw_writer = self._backend._index.writer()
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
# for the write, rather than reusing the process-local cached
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
# once, at construction, and never re-reads it; paperless runs
# several long-lived processes (Granian workers, Celery workers)
# that take turns writing under the file lock above. A cached,
# long-lived writer index would carry a stale managed-files view
# and, on commit, overwrite .managed.json with that stale view -
# permanently losing track of segment files other processes
# registered in the meantime, so they can never be garbage
# collected. Reopening fresh here always picks up the current
# on-disk state. The long-lived self._backend._index is used for
# reads only and is reloaded (not reopened) after commit below.
write_index = tantivy.Index(
build_schema(),
path=str(self._backend._path),
)
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
self._raw_writer = write_index.writer()
else:
self._raw_writer = self._backend._index.writer()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
+3 -52
View File
@@ -88,7 +88,6 @@ from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template
from documents.validators import uri_validator
from documents.validators import url_validator
from documents.versioning import sort_versions_newest_first
if TYPE_CHECKING:
from collections.abc import Iterable
@@ -1117,13 +1116,9 @@ class DocumentSerializer(
"added",
"checksum",
"version_label",
"root_document_id",
"version_index",
)
versions = [*versions_qs, root_doc]
versions = sort_versions_newest_first(versions)
def build_info(doc: Document) -> _DocumentVersionInfo:
return {
"id": doc.id,
@@ -1133,7 +1128,9 @@ class DocumentSerializer(
"is_root": doc.id == root_doc.id,
}
return [build_info(doc) for doc in versions]
info = [build_info(doc) for doc in versions]
info.sort(key=lambda item: item["id"], reverse=True)
return info
def get_original_file_name(self, obj) -> str | None:
return obj.original_filename
@@ -1680,52 +1677,6 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
from_webui = serializers.BooleanField(required=False, default=False)
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
root_document_id = serializers.IntegerField(required=True)
version_label = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
max_length=64,
)
def validate_version_label(self, value):
if value is None:
return None
normalized = value.strip()
return normalized or None
def validate(self, attrs):
documents = attrs["documents"]
if len(documents) < 2:
raise serializers.ValidationError(
"At least two documents are required.",
)
if attrs.get("version_label") is not None and len(documents) != 2:
raise serializers.ValidationError(
"version_label can only be used when merging one source document.",
)
if attrs["root_document_id"] not in documents:
raise serializers.ValidationError(
"root_document_id must be one of the selected documents.",
)
selected_documents = Document.objects.filter(id__in=documents)
if selected_documents.filter(root_document__isnull=False).exists():
raise serializers.ValidationError(
"Only top-level documents can be merged as versions.",
)
source_document_ids = set(documents) - {attrs["root_document_id"]}
if Document.global_objects.filter(
root_document_id__in=source_document_ids,
).exists():
raise serializers.ValidationError(
"Documents with existing versions cannot be merged into another document.",
)
return attrs
class EditPdfDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin):
operations = serializers.ListField(required=True)
delete_original = serializers.BooleanField(required=False, default=False)
@@ -1,3 +1,6 @@
import json
from pathlib import Path
import pytest
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
@@ -21,6 +24,17 @@ from documents.tests.factories import UserFactory
pytestmark = [pytest.mark.search, pytest.mark.django_db]
# Extensions of actual Tantivy segment data files, as opposed to its own
# bookkeeping files (meta.json, .managed.json, lock files).
_SEGMENT_FILE_EXTENSIONS = (
".fast",
".fieldnorm",
".idx",
".pos",
".store",
".term",
)
class TestWriteBatch:
"""Test WriteBatch context manager functionality."""
@@ -1014,3 +1028,63 @@ class TestHighlightHits:
hits = backend.highlight_hits("quick", [doc.pk])
assert len(hits) == 0
class TestIndexDirectoryGarbageCollection:
"""Regression tests for Tantivy segment files leaking on disk when
multiple long-lived worker processes (Granian/Celery) take turns writing
to the same on-disk index (issue #13679)."""
def test_no_permanently_orphaned_segment_files_across_worker_processes(
self,
tmp_path: Path,
) -> None:
"""Simulate two long-lived worker processes, each with its own
process-local ``TantivyBackend``/``Index`` opened once at process
start, alternating turns as the writer -- exactly how paperless runs
in production (several Granian + Celery worker processes).
Every segment file physically present on disk must still be tracked
in Tantivy's ``.managed.json`` bookkeeping; otherwise it can never be
garbage collected by anyone again and the index directory grows
without bound.
"""
index_dir = tmp_path / "index"
index_dir.mkdir()
worker_a = TantivyBackend(path=index_dir)
worker_a.open()
worker_b = TantivyBackend(path=index_dir)
worker_b.open()
workers = [worker_a, worker_b]
docs = [
DocumentFactory.create(checksum=f"GC{i}", title=f"gc doc {i}")
for i in range(5)
]
try:
# Alternate writers across many commits, repeatedly upserting the
# same documents so segments accumulate and get superseded,
# forcing the delete+add upsert pattern and eventual merges.
for i in range(30):
worker = workers[i % len(workers)]
doc = docs[i % len(docs)]
worker.add_or_update(doc)
finally:
worker_a.close()
worker_b.close()
managed_path = index_dir / ".managed.json"
managed = set(json.loads(managed_path.read_text()))
on_disk = {
p.name
for p in index_dir.iterdir()
if p.is_file() and p.suffix in _SEGMENT_FILE_EXTENSIONS
}
orphans = on_disk - managed
assert not orphans, (
"Segment files present on disk but absent from Tantivy's "
f".managed.json bookkeeping (permanently un-collectible): {orphans}"
)
@@ -827,67 +827,6 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["content"], "v1-content")
def _make_root_with_out_of_order_versions(self) -> tuple[Document, ...]:
"""
A root whose newest version has a *lower* id than an older one, which is
what merging an existing document in as a version produces.
"""
root = Document.objects.create(
title="root",
checksum="root",
mime_type="application/pdf",
content="root-content",
)
newest = Document.objects.create(
title="newest",
checksum="newest",
mime_type="application/pdf",
content="newest-content",
)
older = Document.objects.create(
title="older",
checksum="older",
mime_type="application/pdf",
root_document=root,
version_index=1,
content="older-content",
)
# Assigned last, so `newest` has the lower id despite being the later version
newest.root_document = root
newest.version_index = 2
newest.save()
return root, newest, older
def test_retrieve_uses_version_index_not_id_for_latest(self) -> None:
root, _, _ = self._make_root_with_out_of_order_versions()
resp = self.client.get(f"/api/documents/{root.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["content"], "newest-content")
def test_list_uses_version_index_not_id_for_latest(self) -> None:
self._make_root_with_out_of_order_versions()
resp = self.client.get("/api/documents/?fields=id,content")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(
[doc["content"] for doc in resp.data["results"]],
["newest-content"],
)
def test_versions_are_listed_newest_first_with_root_last(self) -> None:
root, newest, older = self._make_root_with_out_of_order_versions()
resp = self.client.get(f"/api/documents/{root.id}/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(
[(version["id"], version["is_root"]) for version in resp.data["versions"]],
[(newest.id, False), (older.id, False), (root.id, True)],
)
class TestVersionAwareFilters(TestCase):
def test_title_content_filter_falls_back_to_content(self) -> None:
-1
View File
@@ -48,7 +48,6 @@ class TestApiSchema(APITestCase):
self.assertIn("/api/documents/reprocess/", paths)
self.assertIn("/api/documents/rotate/", paths)
self.assertIn("/api/documents/merge/", paths)
self.assertIn("/api/documents/merge_as_versions/", paths)
self.assertIn("/api/documents/edit_pdf/", paths)
self.assertIn("/api/documents/remove_password/", paths)
@@ -1,546 +0,0 @@
import json
from unittest import mock
from auditlog.models import LogEntry
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APITestCase
from documents.bulk_edit import merge_as_versions
from documents.models import Document
from documents.serialisers import MergeDocumentsAsVersionsSerializer
class TestMergeDocumentsAsVersionsSerializer(TestCase):
def setUp(self) -> None:
self.doc1 = Document.objects.create(checksum="A", title="A")
self.doc2 = Document.objects.create(checksum="B", title="B")
self.doc3 = Document.objects.create(checksum="C", title="C")
def test_accepts_selected_root_document(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc2.id,
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertEqual(
serializer.validated_data,
{
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc2.id,
},
)
def test_requires_at_least_two_documents(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id],
"root_document_id": self.doc1.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"At least two documents are required.",
)
def test_accepts_version_label_for_one_source_document(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc1.id,
"version_label": " Imported ",
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertEqual(serializer.validated_data["version_label"], "Imported")
def test_rejects_version_label_for_multiple_source_documents(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
"root_document_id": self.doc1.id,
"version_label": "Imported",
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"version_label can only be used when merging one source document.",
)
def test_requires_root_document_to_be_selected(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc3.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"root_document_id must be one of the selected documents.",
)
def test_rejects_duplicate_documents(self) -> None:
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc1.id],
"root_document_id": self.doc1.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertIn("documents", serializer.errors)
def test_rejects_selected_version(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [version.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Only top-level documents can be merged as versions.",
)
def test_rejects_source_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Documents with existing versions cannot be merged into another document.",
)
def test_rejects_source_document_with_trashed_versions(self) -> None:
version = Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
version.delete() # trashed, but still points at doc1
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
)
self.assertFalse(serializer.is_valid())
self.assertEqual(
serializer.errors["non_field_errors"][0],
"Documents with existing versions cannot be merged into another document.",
)
def test_allows_root_document_with_versions(self) -> None:
Document.objects.create(
checksum="D",
title="D",
root_document=self.doc1,
version_index=1,
)
serializer = MergeDocumentsAsVersionsSerializer(
data={
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc1.id,
},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
class TestMergeDocumentsAsVersions(TestCase):
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_merges_documents_in_selection_order(
self,
get_backend_mock,
bulk_update_mock,
status_manager_mock,
) -> None:
root = Document.objects.create(checksum="A", title="Root")
existing_version = Document.objects.create(
checksum="B",
title="Existing version",
root_document=root,
version_index=3,
)
source1 = Document.objects.create(
checksum="C",
title="Source 1",
archive_serial_number=1,
)
source2 = Document.objects.create(
checksum="D",
title="Source 2",
archive_serial_number=2,
)
original_modified = root.modified
result = merge_as_versions(
[source2.id, root.id, source1.id],
root_document_id=root.id,
)
self.assertEqual(result, "OK")
source1.refresh_from_db()
source2.refresh_from_db()
root.refresh_from_db()
# source2 was selected first, so it becomes the older of the two versions
self.assertEqual(source2.root_document_id, root.id)
self.assertEqual(source2.version_index, 4)
self.assertEqual(source1.root_document_id, root.id)
self.assertEqual(source1.version_index, 5)
self.assertIsNone(source1.archive_serial_number)
self.assertIsNone(source2.archive_serial_number)
# The root had no ASN of its own, so it takes the first one
self.assertEqual(root.archive_serial_number, 2)
self.assertGreater(root.modified, original_modified)
self.assertEqual(existing_version.root_document_id, root.id)
batch = get_backend_mock.return_value.batch_update.return_value.__enter__.return_value
self.assertEqual(
[call.args[0] for call in batch.remove.call_args_list],
[source2.id, source1.id],
)
bulk_update_mock.assert_called_once_with(
kwargs={"document_ids": [root.id]},
headers={"trigger_source": "system"},
)
status_manager_mock.return_value.send_documents_deleted.assert_called_once_with(
[source2.id, source1.id],
)
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_root_keeps_its_own_archive_serial_number(self, *_mocks) -> None:
root = Document.objects.create(
checksum="A",
title="Root",
archive_serial_number=1,
)
source = Document.objects.create(
checksum="B",
title="Source",
archive_serial_number=2,
)
with self.assertLogs("paperless.bulk_edit", level="WARNING") as logs:
merge_as_versions([root.id, source.id], root_document_id=root.id)
root.refresh_from_db()
source.refresh_from_db()
self.assertEqual(root.archive_serial_number, 1)
self.assertIsNone(source.archive_serial_number)
# Dropping an ASN is not silent
self.assertIn("[2]", logs.output[0])
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_root_without_asn_takes_the_source_archive_serial_number(
self,
*_mocks,
) -> None:
root = Document.objects.create(checksum="A", title="Root")
source = Document.objects.create(
checksum="B",
title="Source",
archive_serial_number=7,
)
merge_as_versions([root.id, source.id], root_document_id=root.id)
root.refresh_from_db()
source.refresh_from_db()
self.assertEqual(root.archive_serial_number, 7)
self.assertIsNone(source.archive_serial_number)
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_writes_audit_log_entry(self, *_mocks) -> None:
user = User.objects.create_user(username="merger")
root = Document.objects.create(checksum="A", title="Root")
source = Document.objects.create(checksum="B", title="Source")
LogEntry.objects.all().delete()
merge_as_versions([root.id, source.id], root_document_id=root.id, user=user)
entry = LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(Document),
object_id=root.id,
).first()
self.assertIsNotNone(entry)
self.assertEqual(entry.actor, user)
self.assertEqual(entry.action, LogEntry.Action.UPDATE)
self.assertEqual(entry.changes, {"Merged As Versions": ["None", [source.id]]})
self.assertEqual(entry.additional_data["version_ids"], [source.id])
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_sets_version_label_for_one_source_document(
self,
_get_backend_mock,
_bulk_update_mock,
_status_manager_mock,
) -> None:
root = Document.objects.create(checksum="A", title="Root")
source = Document.objects.create(checksum="B", title="Source")
merge_as_versions(
[root.id, source.id],
root_document_id=root.id,
version_label="Imported",
)
source.refresh_from_db()
self.assertEqual(source.version_label, "Imported")
class TestMergeDocumentsAsVersionsAPI(APITestCase):
def setUp(self) -> None:
self.user = User.objects.create_user(username="user")
self.user.user_permissions.add(
Permission.objects.get(codename="change_document"),
Permission.objects.get(codename="view_document"),
Permission.objects.get(codename="delete_document"),
)
self.doc1 = Document.objects.create(
checksum="A",
title="A",
owner=self.user,
)
self.doc2 = Document.objects.create(
checksum="B",
title="B",
owner=self.user,
)
self.client.force_authenticate(user=self.user)
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_merges_documents_as_versions(self, merge_mock) -> None:
merge_mock.return_value = "OK"
merge_mock.__name__ = "merge_as_versions"
response = self.client.post(
"/api/documents/merge_as_versions/",
json.dumps(
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
"version_label": "Imported",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {"result": "OK"})
merge_mock.assert_called_once_with(
[self.doc1.id, self.doc2.id],
root_document_id=self.doc2.id,
version_label="Imported",
user=self.user,
)
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_requires_change_permission(self, merge_mock) -> None:
merge_mock.__name__ = "merge_as_versions"
user = User.objects.create_user(username="no-change")
self.doc1.owner = user
self.doc1.save()
self.doc2.owner = user
self.doc2.save()
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc1.id,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
merge_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_requires_delete_permission(self, merge_mock) -> None:
merge_mock.__name__ = "merge_as_versions"
# Owns them and may change them, but may not make them stop being documents
user = User.objects.create_user(username="no-delete")
user.user_permissions.add(
Permission.objects.get(codename="change_document"),
Permission.objects.get(codename="view_document"),
)
for doc in (self.doc1, self.doc2):
doc.owner = user
doc.save()
self.client.force_authenticate(user=user)
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc1.id,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
merge_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_rejects_unselected_root(self, merge_mock) -> None:
doc3 = Document.objects.create(
checksum="C",
title="C",
owner=self.user,
)
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": doc3.id,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
merge_mock.assert_not_called()
@mock.patch("documents.views.bulk_edit.merge_as_versions")
def test_rejects_source_document_with_versions(self, merge_mock) -> None:
Document.objects.create(
checksum="C",
title="C",
root_document=self.doc1,
version_index=1,
owner=self.user,
)
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
merge_mock.assert_not_called()
self.doc1.refresh_from_db()
self.assertIsNone(self.doc1.root_document_id)
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_merges_and_returns_documents_as_versions(
self,
get_backend_mock,
bulk_update_mock,
status_manager_mock,
) -> None:
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [self.doc1.id, self.doc2.id],
"root_document_id": self.doc2.id,
"version_label": "Imported",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.doc1.refresh_from_db()
self.assertEqual(self.doc1.root_document_id, self.doc2.id)
self.assertEqual(self.doc1.version_label, "Imported")
detail_response = self.client.get(
f"/api/documents/{self.doc2.id}/?fields=id,versions",
)
self.assertEqual(detail_response.status_code, status.HTTP_200_OK)
versions = detail_response.data["versions"]
self.assertEqual(
{version["id"] for version in versions},
{self.doc1.id, self.doc2.id},
)
self.assertEqual(
[version["id"] for version in versions if version["is_root"]],
[self.doc2.id],
)
batch = get_backend_mock.return_value.batch_update.return_value.__enter__.return_value
batch.remove.assert_called_once_with(self.doc1.id)
bulk_update_mock.assert_called_once_with(
kwargs={"document_ids": [self.doc2.id]},
headers={"trigger_source": "system"},
)
status_manager_mock.return_value.send_documents_deleted.assert_called_once_with(
[self.doc1.id],
)
@mock.patch("documents.bulk_edit.DocumentsStatusManager")
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.search.get_backend")
def test_chosen_order_survives_to_the_versions_list(self, *_mocks) -> None:
doc3 = Document.objects.create(checksum="C", title="C", owner=self.user)
# Deliberately not in id order, as dragging the dialog rows produces
ordered = [doc3.id, self.doc1.id]
response = self.client.post(
"/api/documents/merge_as_versions/",
{
"documents": [*ordered, self.doc2.id],
"root_document_id": self.doc2.id,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
detail_response = self.client.get(
f"/api/documents/{self.doc2.id}/?fields=id,versions",
)
# Newest first, so the reverse of the order they were merged in
self.assertEqual(
[version["id"] for version in detail_response.data["versions"]],
[self.doc1.id, doc3.id, self.doc2.id],
)
+1 -23
View File
@@ -5,34 +5,12 @@ from enum import StrEnum
from typing import TYPE_CHECKING
from typing import Any
from django.db.models import F
from django.db.models import QuerySet
from documents.models import Document
if TYPE_CHECKING:
from rest_framework.request import Request
def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
"""
Sorts versions so the newest one comes first using version_index and not on id,
because an existing document can be merged in as a version
"""
return documents.order_by(F("version_index").desc(nulls_last=True), "-id")
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
"""
Same sorting as versions_newest_first()
"""
return sorted(
documents,
key=lambda doc: (doc.version_index or 0, doc.id),
reverse=True,
)
class VersionResolutionError(StrEnum):
INVALID = "invalid"
NOT_FOUND = "not_found"
@@ -73,7 +51,7 @@ def get_latest_version_for_root(
include_deleted: bool = False,
) -> Document:
manager = _document_manager(include_deleted=include_deleted)
latest = versions_newest_first(manager.filter(root_document=root_doc)).first()
latest = manager.filter(root_document=root_doc).order_by("-id").first()
return latest or root_doc
+9 -45
View File
@@ -195,7 +195,6 @@ from documents.serialisers import DocumentVersionLabelSerializer
from documents.serialisers import DocumentVersionSerializer
from documents.serialisers import EditPdfDocumentsSerializer
from documents.serialisers import EmailSerializer
from documents.serialisers import MergeDocumentsAsVersionsSerializer
from documents.serialisers import MergeDocumentsSerializer
from documents.serialisers import NotesSerializer
from documents.serialisers import PostDocumentSerializer
@@ -233,7 +232,6 @@ from documents.versioning import get_latest_version_for_root
from documents.versioning import get_request_version_param
from documents.versioning import get_root_document
from documents.versioning import resolve_requested_version_for_root
from documents.versioning import versions_newest_first
from paperless import version
from paperless.celery import app as celery_app
from paperless.config import AIConfig
@@ -1077,9 +1075,9 @@ class DocumentViewSet(
def get_queryset(self):
latest_version_content = Subquery(
versions_newest_first(
Document.objects.filter(root_document=OuterRef("pk")),
).values("content")[:1],
Document.objects.filter(root_document=OuterRef("pk"))
.order_by("-id")
.values("content")[:1],
)
# A correlated subquery avoids the LEFT JOIN + Count() this used to
# be, which forced a GROUP BY aggregate over every matching document
@@ -1115,7 +1113,6 @@ class DocumentViewSet(
"checksum",
"version_label",
"root_document_id",
"version_index",
),
),
"tags",
@@ -2136,9 +2133,11 @@ class DocumentViewSet(
},
)
current = versions_newest_first(
Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc)),
).first()
current = (
Document.objects.filter(Q(id=root_doc.id) | Q(root_document=root_doc))
.order_by("-id")
.first()
)
document_updated.send(
sender=self.__class__,
@@ -2766,12 +2765,8 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
"delete_pages",
"edit_pdf",
"remove_password",
"merge_as_versions",
}
# merge_as_versions doesn't queue any consume tasks
METHOD_NAMES_REQUIRING_TRIGGER_SOURCE = METHOD_NAMES_REQUIRING_USER - {
"merge_as_versions",
}
METHOD_NAMES_REQUIRING_TRIGGER_SOURCE = METHOD_NAMES_REQUIRING_USER
def _has_document_permissions(
self,
@@ -2812,7 +2807,6 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
bulk_edit.rotate,
bulk_edit.delete_pages,
bulk_edit.edit_pdf,
bulk_edit.merge_as_versions,
bulk_edit.remove_password,
]
)
@@ -2843,9 +2837,6 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
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")
@@ -3102,33 +3093,6 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_merge_as_versions",
description="Merge selected documents as versions of a chosen root document",
responses={
200: inline_serializer(
name="MergeDocumentsAsVersionsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
serializer_class = MergeDocumentsAsVersionsSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
return self._execute_document_action(
method=bulk_edit.merge_as_versions,
validated_data=serializer.validated_data,
operation_label="document merge as versions",
)
@extend_schema_view(
post=extend_schema(
operation_id="documents_delete",
-6
View File
@@ -27,7 +27,6 @@ from documents.views import EditPdfDocumentsView
from documents.views import GlobalSearchView
from documents.views import IndexView
from documents.views import LogViewSet
from documents.views import MergeDocumentsAsVersionsView
from documents.views import MergeDocumentsView
from documents.views import PostDocumentView
from documents.views import RemoteVersionView
@@ -173,11 +172,6 @@ urlpatterns = [
MergeDocumentsView.as_view(),
name="merge_documents",
),
re_path(
"^merge_as_versions/",
MergeDocumentsAsVersionsView.as_view(),
name="merge_documents_as_versions",
),
re_path(
"^edit_pdf/",
EditPdfDocumentsView.as_view(),