Bulk edit custom fields

This commit is contained in:
shamoon 2024-04-23 23:50:50 -07:00
parent b399790681
commit 7241c85f95
9 changed files with 581 additions and 7 deletions

View File

@ -4,7 +4,7 @@
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button> </button>
<div class="dropdown-menu date-dropdown shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="row d-flex"> <div class="row d-flex">
<div class="col border-end"> <div class="col border-end">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">

View File

@ -74,6 +74,20 @@
(apply)="setStoragePaths($event)"> (apply)="setStoragePaths($event)">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
} }
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[items]="customFields"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts"
(apply)="setCustomFields($event)">
</pngx-filterable-dropdown>
}
</div> </div>
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar"> <div class="btn-toolbar">

View File

@ -55,6 +55,9 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
const selectionData: SelectionData = { const selectionData: SelectionData = {
selected_tags: [ selected_tags: [
@ -69,8 +72,8 @@ const selectionData: SelectionData = {
{ id: 55, document_count: 0 }, { id: 55, document_count: 0 },
], ],
selected_custom_fields: [ selected_custom_fields: [
{ id: 1, document_count: 3 }, { id: 77, document_count: 3 },
{ id: 2, document_count: 0 }, { id: 88, document_count: 0 },
], ],
} }
@ -86,6 +89,7 @@ describe('BulkEditorComponent', () => {
let correspondentsService: CorrespondentService let correspondentsService: CorrespondentService
let documentTypeService: DocumentTypeService let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService let storagePathService: StoragePathService
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
beforeEach(async () => { beforeEach(async () => {
@ -152,6 +156,18 @@ describe('BulkEditorComponent', () => {
}), }),
}, },
}, },
{
provide: CustomFieldsService,
useValue: {
listAll: () =>
of({
results: [
{ id: 77, name: 'customfield1' },
{ id: 88, name: 'customfield2' },
],
}),
},
},
FilterPipe, FilterPipe,
SettingsService, SettingsService,
{ {
@ -193,6 +209,7 @@ describe('BulkEditorComponent', () => {
correspondentsService = TestBed.inject(CorrespondentService) correspondentsService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService) documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService) storagePathService = TestBed.inject(StoragePathService)
customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(BulkEditorComponent) fixture = TestBed.createComponent(BulkEditorComponent)
@ -266,6 +283,22 @@ describe('BulkEditorComponent', () => {
expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1) expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
}) })
it('should apply selection data to custom fields menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(
component.customFieldsSelectionModel.getSelectedItems()
).toHaveLength(0)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 5, 7]))
jest
.spyOn(documentService, 'getSelectionData')
.mockReturnValue(of(selectionData))
component.openCustomFieldsDropdown()
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
})
it('should execute modify tags bulk operation', () => { it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest jest
@ -683,6 +716,122 @@ describe('BulkEditorComponent', () => {
) )
}) })
it('should execute modify custom fields bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [{ id: 102 }],
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'modify_custom_fields',
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
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(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [{ id: 102 }],
})
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
// coverage for modal messages
component.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
component.setCustomFields({
itemsToAdd: [{ id: 101 }, { id: 102 }],
itemsToRemove: [],
})
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [{ id: 101 }, { id: 102 }],
})
component.setCustomFields({
itemsToAdd: [{ id: 100 }],
itemsToRemove: [{ id: 101 }, { id: 102 }],
})
})
it('should set modal dialog text accordingly for custom fields edit confirmation', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [{ id: 101, name: 'CustomField 101' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the custom field "CustomField 101" from 2 selected document(s).'
)
modal.close()
component.setCustomFields({
itemsToAdd: [{ id: 101, name: 'CustomField 101' }],
itemsToRemove: [],
})
expect(modal.componentInstance.message).toEqual(
'This operation will assign the custom field "CustomField 101" to 2 selected document(s).'
)
})
it('should only execute bulk operations when changes are detected', () => { it('should only execute bulk operations when changes are detected', () => {
component.setTags({ component.setTags({
itemsToAdd: [], itemsToAdd: [],
@ -700,6 +849,10 @@ describe('BulkEditorComponent', () => {
itemsToAdd: [], itemsToAdd: [],
itemsToRemove: [], itemsToRemove: [],
}) })
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [],
})
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
@ -1183,4 +1336,56 @@ describe('BulkEditorComponent', () => {
) )
expect(component.storagePaths).toEqual(storagePaths.results) expect(component.storagePaths).toEqual(storagePaths.results)
}) })
it('should support create new custom field', () => {
const name = 'New Custom Field'
const newCustomField = { id: 101, name: 'New Custom Field' }
const customFields: Results<CustomField> = {
results: [
{
id: 1,
name: 'Custom Field 1',
data_type: CustomFieldDataType.String,
},
{
id: 2,
name: 'Custom Field 2',
data_type: CustomFieldDataType.String,
},
],
count: 2,
all: [1, 2],
}
const modalInstance = {
componentInstance: {
dialogMode: EditDialogMode.CREATE,
object: { name },
succeeded: of(newCustomField),
},
}
const customFieldsListAllSpy = jest.spyOn(customFieldsService, 'listAll')
customFieldsListAllSpy.mockReturnValue(of(customFields))
const customFieldsSelectionModelToggleSpy = jest.spyOn(
component.customFieldsSelectionModel,
'toggle'
)
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
component.createCustomField(name)
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
CustomFieldEditDialogComponent,
{ backdrop: 'static' }
)
expect(customFieldsListAllSpy).toHaveBeenCalled()
expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
newCustomField.id
)
expect(component.customFields).toEqual(customFields.results)
})
}) })

View File

@ -41,6 +41,9 @@ import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/docume
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@Component({ @Component({
selector: 'pngx-bulk-editor', selector: 'pngx-bulk-editor',
@ -55,15 +58,18 @@ export class BulkEditorComponent
correspondents: Correspondent[] correspondents: Correspondent[]
documentTypes: DocumentType[] documentTypes: DocumentType[]
storagePaths: StoragePath[] storagePaths: StoragePath[]
customFields: CustomField[]
tagSelectionModel = new FilterableDropdownSelectionModel() tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel()
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[] documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[] storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
awaitingDownload: boolean awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@ -85,6 +91,7 @@ export class BulkEditorComponent
private settings: SettingsService, private settings: SettingsService,
private toastService: ToastService, private toastService: ToastService,
private storagePathService: StoragePathService, private storagePathService: StoragePathService,
private customFieldService: CustomFieldsService,
private permissionService: PermissionsService private permissionService: PermissionsService
) { ) {
super() super()
@ -166,6 +173,17 @@ export class BulkEditorComponent
.pipe(first()) .pipe(first())
.subscribe((result) => (this.storagePaths = result.results)) .subscribe((result) => (this.storagePaths = result.results))
} }
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.CustomField
)
) {
this.customFieldService
.listAll()
.pipe(first())
.subscribe((result) => (this.customFields = result.results))
}
this.downloadForm this.downloadForm
.get('downloadFileTypeArchive') .get('downloadFileTypeArchive')
@ -297,6 +315,19 @@ export class BulkEditorComponent
}) })
} }
openCustomFieldsDropdown() {
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.customFieldDocumentCounts = s.selected_custom_fields
this.applySelectionData(
s.selected_custom_fields,
this.customFieldsSelectionModel
)
})
}
private _localizeList(items: MatchingModel[]) { private _localizeList(items: MatchingModel[]) {
if (items.length == 0) { if (items.length == 0) {
return '' return ''
@ -495,6 +526,74 @@ export class BulkEditorComponent
} }
} }
setCustomFields(changedCustomFields: ChangedItems) {
if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 0
)
return
if (this.showConfirmationDialogs) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm custom field assignment`
if (
changedCustomFields.itemsToAdd.length == 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
let customField = changedCustomFields.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length > 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} to ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 1
) {
let customField = changedCustomFields.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} from ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} and remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} on ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.executeBulkOperation(modal, 'modify_custom_fields', {
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
remove_custom_fields: changedCustomFields.itemsToRemove.map(
(f) => f.id
),
})
})
} else {
this.executeBulkOperation(null, 'modify_custom_fields', {
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
remove_custom_fields: changedCustomFields.itemsToRemove.map(
(f) => f.id
),
})
}
}
createTag(name: string) { createTag(name: string) {
let modal = this.modalService.open(TagEditDialogComponent, { let modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static', backdrop: 'static',
@ -581,6 +680,27 @@ export class BulkEditorComponent
}) })
} }
createCustomField(name: string) {
let modal = this.modalService.open(CustomFieldEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.object = { name }
modal.componentInstance.succeeded
.pipe(
switchMap((newCustomField) => {
return this.customFieldService
.listAll()
.pipe(map((customFields) => ({ newCustomField, customFields })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCustomField, customFields }) => {
this.customFields = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id)
})
}
applyDelete() { applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',

View File

@ -82,8 +82,6 @@
[documentCounts]="customFieldDocumentCounts" [documentCounts]="customFieldDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown> [allowSelectNone]="true"></pngx-filterable-dropdown>
} }
</div>
<div class="d-flex flex-wrap gap-2">
<pngx-dates-dropdown <pngx-dates-dropdown
title="Dates" i18n-title title="Dates" i18n-title
(datesSet)="updateRules()" (datesSet)="updateRules()"
@ -94,8 +92,6 @@
[(addedDateAfter)]="dateAddedAfter" [(addedDateAfter)]="dateAddedAfter"
[(addedRelativeDate)]="dateAddedRelativeDate"> [(addedRelativeDate)]="dateAddedRelativeDate">
</pngx-dates-dropdown> </pngx-dates-dropdown>
</div>
<div class="d-flex flex-wrap">
<pngx-permissions-filter-dropdown <pngx-permissions-filter-dropdown
title="Permissions" i18n-title title="Permissions" i18n-title
(ownerFilterSet)="updateRules()" (ownerFilterSet)="updateRules()"

View File

@ -12,6 +12,7 @@ from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
@ -120,6 +121,30 @@ def modify_tags(doc_ids, add_tags, remove_tags):
return "OK" return "OK"
def modify_custom_fields(doc_ids, add_custom_fields, remove_custom_fields):
qs = Document.objects.filter(id__in=doc_ids)
affected_docs = [doc.id for doc in qs]
fields_to_add = []
for field in add_custom_fields:
for doc_id in affected_docs:
fields_to_add.append(
CustomFieldInstance(
document_id=doc_id,
field_id=field,
),
)
CustomFieldInstance.objects.bulk_create(fields_to_add)
CustomFieldInstance.objects.filter(
document_id__in=affected_docs,
field_id__in=remove_custom_fields,
).delete()
bulk_update_documents.delay(document_ids=affected_docs)
return "OK"
def delete(doc_ids): def delete(doc_ids):
Document.objects.filter(id__in=doc_ids).delete() Document.objects.filter(id__in=doc_ids).delete()

View File

@ -905,6 +905,7 @@ class BulkEditSerializer(
"add_tag", "add_tag",
"remove_tag", "remove_tag",
"modify_tags", "modify_tags",
"modify_custom_fields",
"delete", "delete",
"redo_ocr", "redo_ocr",
"set_permissions", "set_permissions",
@ -929,6 +930,17 @@ class BulkEditSerializer(
f"Some tags in {name} don't exist or were specified twice.", f"Some tags in {name} don't exist or were specified twice.",
) )
def _validate_custom_field_id_list(self, custom_fields, name="custom_fields"):
if not isinstance(custom_fields, list):
raise serializers.ValidationError(f"{name} must be a list")
if not all(isinstance(i, int) for i in custom_fields):
raise serializers.ValidationError(f"{name} must be a list of integers")
count = CustomField.objects.filter(id__in=custom_fields).count()
if not count == len(custom_fields):
raise serializers.ValidationError(
f"Some custom fields in {name} don't exist or were specified twice.",
)
def validate_method(self, method): def validate_method(self, method):
if method == "set_correspondent": if method == "set_correspondent":
return bulk_edit.set_correspondent return bulk_edit.set_correspondent
@ -942,6 +954,8 @@ class BulkEditSerializer(
return bulk_edit.remove_tag return bulk_edit.remove_tag
elif method == "modify_tags": elif method == "modify_tags":
return bulk_edit.modify_tags return bulk_edit.modify_tags
elif method == "modify_custom_fields":
return bulk_edit.modify_custom_fields
elif method == "delete": elif method == "delete":
return bulk_edit.delete return bulk_edit.delete
elif method == "redo_ocr": elif method == "redo_ocr":
@ -1017,6 +1031,23 @@ class BulkEditSerializer(
else: else:
raise serializers.ValidationError("remove_tags not specified") raise serializers.ValidationError("remove_tags not specified")
def _validate_parameters_modify_custom_fields(self, parameters):
if "add_custom_fields" in parameters:
self._validate_custom_field_id_list(
parameters["add_custom_fields"],
"add_custom_fields",
)
else:
raise serializers.ValidationError("add_custom_fields not specified")
if "remove_custom_fields" in parameters:
self._validate_custom_field_id_list(
parameters["remove_custom_fields"],
"remove_custom_fields",
)
else:
raise serializers.ValidationError("remove_custom_fields not specified")
def _validate_owner(self, owner): def _validate_owner(self, owner):
ownerUser = User.objects.get(pk=owner) ownerUser = User.objects.get(pk=owner)
if ownerUser is None: if ownerUser is None:
@ -1079,6 +1110,8 @@ class BulkEditSerializer(
self._validate_parameters_modify_tags(parameters) self._validate_parameters_modify_tags(parameters)
elif method == bulk_edit.set_storage_path: elif method == bulk_edit.set_storage_path:
self._validate_storage_path(parameters) self._validate_storage_path(parameters)
elif method == bulk_edit.modify_custom_fields:
self._validate_parameters_modify_custom_fields(parameters)
elif method == bulk_edit.set_permissions: elif method == bulk_edit.set_permissions:
self._validate_parameters_set_permissions(parameters) self._validate_parameters_set_permissions(parameters)
elif method == bulk_edit.rotate: elif method == bulk_edit.rotate:

View File

@ -7,6 +7,7 @@ from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
@ -49,6 +50,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.doc3.tags.add(self.t2) self.doc3.tags.add(self.t2)
self.doc4.tags.add(self.t1, self.t2) self.doc4.tags.add(self.t1, self.t2)
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}") self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
self.cf1 = CustomField.objects.create(name="cf1", data_type="text")
self.cf2 = CustomField.objects.create(name="cf2", data_type="text")
@mock.patch("documents.serialisers.bulk_edit.set_correspondent") @mock.patch("documents.serialisers.bulk_edit.set_correspondent")
def test_api_set_correspondent(self, m): def test_api_set_correspondent(self, m):
@ -222,6 +225,135 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called() m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields(self, m):
m.return_value = "OK"
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc3.id],
"method": "modify_custom_fields",
"parameters": {
"add_custom_fields": [self.cf1.id],
"remove_custom_fields": [self.cf2.id],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
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])
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields_invalid_params(self, m):
"""
GIVEN:
- API data to modify custom fields is malformed
WHEN:
- API to edit custom fields is called
THEN:
- API returns HTTP 400
- modify_custom_fields is not called
"""
m.return_value = "OK"
# Missing add_custom_fields
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc3.id],
"method": "modify_custom_fields",
"parameters": {
"add_custom_fields": [self.cf1.id],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
# Missing remove_custom_fields
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc3.id],
"method": "modify_custom_fields",
"parameters": {
"remove_custom_fields": [self.cf1.id],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
# Not a list
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc3.id],
"method": "modify_custom_fields",
"parameters": {
"add_custom_fields": self.cf1.id,
"remove_custom_fields": self.cf2.id,
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
# Not a list of integers
# Missing remove_custom_fields
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc3.id],
"method": "modify_custom_fields",
"parameters": {
"add_custom_fields": ["foo"],
"remove_custom_fields": ["bar"],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
# Custom field ID not found
# Missing remove_custom_fields
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
{
"documents": [self.doc1.id, self.doc3.id],
"method": "modify_custom_fields",
"parameters": {
"add_custom_fields": [self.cf1.id],
"remove_custom_fields": [99],
},
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
m.assert_not_called()
@mock.patch("documents.serialisers.bulk_edit.delete") @mock.patch("documents.serialisers.bulk_edit.delete")
def test_api_delete(self, m): def test_api_delete(self, m):
m.return_value = "OK" m.return_value = "OK"

View File

@ -11,6 +11,8 @@ from guardian.shortcuts import get_users_with_perms
from documents import bulk_edit from documents import bulk_edit
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
@ -186,6 +188,53 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
# TODO: doc3 should not be affected, but the query for that is rather complicated # TODO: doc3 should not be affected, but the query for that is rather complicated
self.assertCountEqual(kwargs["document_ids"], [self.doc2.id, self.doc3.id]) self.assertCountEqual(kwargs["document_ids"], [self.doc2.id, self.doc3.id])
def test_modify_custom_fields(self):
cf = CustomField.objects.create(
name="cf1",
data_type=CustomField.FieldDataType.STRING,
)
cf2 = CustomField.objects.create(
name="cf2",
data_type=CustomField.FieldDataType.INT,
)
cf3 = CustomField.objects.create(
name="cf3",
data_type=CustomField.FieldDataType.STRING,
)
CustomFieldInstance.objects.create(
document=self.doc1,
field=cf,
)
CustomFieldInstance.objects.create(
document=self.doc2,
field=cf,
)
CustomFieldInstance.objects.create(
document=self.doc2,
field=cf3,
)
bulk_edit.modify_custom_fields(
[self.doc1.id, self.doc2.id],
add_custom_fields=[cf2.id],
remove_custom_fields=[cf.id],
)
self.doc1.refresh_from_db()
self.doc2.refresh_from_db()
self.assertEqual(
self.doc1.custom_fields.count(),
1,
)
self.assertEqual(
self.doc2.custom_fields.count(),
2,
)
self.async_task.assert_called_once()
args, kwargs = self.async_task.call_args
self.assertCountEqual(kwargs["document_ids"], [self.doc1.id, self.doc2.id])
def test_delete(self): def test_delete(self):
self.assertEqual(Document.objects.count(), 5) self.assertEqual(Document.objects.count(), 5)
bulk_edit.delete([self.doc1.id, self.doc2.id]) bulk_edit.delete([self.doc1.id, self.doc2.id])