diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
index b26ad9024..658d3dd6e 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
@@ -29,6 +29,7 @@ import {
FILTER_CORRESPONDENT,
FILTER_DOCUMENT_TYPE,
FILTER_STORAGE_PATH,
+ FILTER_WAREHOUSE,
FILTER_HAS_TAGS_ALL,
FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE,
@@ -37,6 +38,7 @@ import { Correspondent } from 'src/app/data/correspondent'
import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path'
+import { Warehouse } from 'src/app/data/warehouse'
import { Tag } from 'src/app/data/tag'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -52,6 +54,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
+import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -59,6 +62,7 @@ import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { WarehouseEditDialogComponent } from '../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { DateComponent } from '../common/input/date/date.component'
import { NumberComponent } from '../common/input/number/number.component'
import { PermissionsFormComponent } from '../common/input/permissions/permissions-form/permissions-form.component'
@@ -165,6 +169,7 @@ describe('DocumentDetailComponent', () => {
DocumentTypeEditDialogComponent,
CorrespondentEditDialogComponent,
StoragePathEditDialogComponent,
+ WarehouseEditDialogComponent,
IfOwnerDirective,
PermissionsFormComponent,
SafeHtmlPipe,
@@ -220,6 +225,20 @@ describe('DocumentDetailComponent', () => {
}),
},
},
+ {
+ provide: WarehouseService,
+ useValue: {
+ listAll: () =>
+ of({
+ results: [
+ {
+ id: 41,
+ name: 'Warehouse41',
+ },
+ ],
+ }),
+ },
+ },
{
provide: UserService,
useValue: {
@@ -366,6 +385,7 @@ describe('DocumentDetailComponent', () => {
expect(component.correspondents).toBeUndefined()
expect(component.documentTypes).toBeUndefined()
expect(component.storagePaths).toBeUndefined()
+ expect(component.warehouses).toBeUndefined()
expect(component.users).toBeUndefined()
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone(
@@ -377,6 +397,9 @@ describe('DocumentDetailComponent', () => {
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/storage_paths/`
)
+ httpTestingController.expectNone(
+ `${environment.apiBaseUrl}documents/warehouses/`
+ )
currentUserCan = true
})
@@ -419,6 +442,20 @@ describe('DocumentDetailComponent', () => {
expect(component.documentForm.get('storage_path').value).toEqual(12)
})
+ it('should support creating warehouse', () => {
+ initNormally()
+ let openModal: NgbModalRef
+ modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
+ const modalSpy = jest.spyOn(modalService, 'open')
+ component.createWarehouse('NewWarehouse12')
+ expect(modalSpy).toHaveBeenCalled()
+ openModal.componentInstance.succeeded.next({
+ id: 12,
+ name: 'NewWarehouse12',
+ })
+ expect(component.documentForm.get('warehouse').value).toEqual(12)
+ })
+
it('should allow dischard changes', () => {
initNormally()
component.title = 'Foo Bar'
@@ -819,6 +856,24 @@ describe('DocumentDetailComponent', () => {
])
})
+ it('should support quick filtering by warehouse', () => {
+ initNormally()
+ const object = {
+ id: 22,
+ name: 'Warehouse22',
+ type: 'Warehouse',
+ parent_warehouse: 22,
+ } as Warehouse
+ const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
+ component.filterDocuments([object])
+ expect(qfSpy).toHaveBeenCalledWith([
+ {
+ rule_type: FILTER_WAREHOUSE,
+ value: object.id.toString(),
+ },
+ ])
+ })
+
it('should support quick filtering by all tags', () => {
initNormally()
const object1 = {
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts
index d8f63faf2..863909ab6 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.ts
@@ -44,10 +44,14 @@ import {
FILTER_FULLTEXT_MORELIKE,
FILTER_HAS_TAGS_ALL,
FILTER_STORAGE_PATH,
+ FILTER_WAREHOUSE,
} from 'src/app/data/filter-rule-type'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { StoragePath } from 'src/app/data/storage-path'
import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { WarehouseService } from 'src/app/services/rest/warehouse.service'
+import { Warehouse } from 'src/app/data/warehouse'
+import { WarehouseEditDialogComponent } from '../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import {
PermissionAction,
@@ -134,6 +138,8 @@ export class DocumentDetailComponent
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
+ warehouses: Warehouse[]
+
documentForm: FormGroup = new FormGroup({
title: new FormControl(''),
@@ -142,6 +148,7 @@ export class DocumentDetailComponent
correspondent: new FormControl(),
document_type: new FormControl(),
storage_path: new FormControl(),
+ warehouses: new FormControl(),
archive_serial_number: new FormControl(),
tags: new FormControl([]),
permissions_form: new FormControl(null),
@@ -197,6 +204,7 @@ export class DocumentDetailComponent
private toastService: ToastService,
private settings: SettingsService,
private storagePathService: StoragePathService,
+ private warehouseService: WarehouseService,
private permissionsService: PermissionsService,
private userService: UserService,
private customFieldsService: CustomFieldsService,
@@ -285,6 +293,17 @@ export class DocumentDetailComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.storagePaths = result.results))
}
+ if (
+ this.permissionsService.currentUserCan(
+ PermissionAction.View,
+ PermissionType.Warehouse
+ )
+ ) {
+ this.warehouseService
+ .listAll()
+ .pipe(first(), takeUntil(this.unsubscribeNotifier))
+ .subscribe((result) => (this.warehouses = result.results))
+ }
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
@@ -408,6 +427,7 @@ export class DocumentDetailComponent
correspondent: doc.correspondent,
document_type: doc.document_type,
storage_path: doc.storage_path,
+ warehouses: doc.warehouses,
archive_serial_number: doc.archive_serial_number,
tags: [...doc.tags],
permissions_form: {
@@ -602,6 +622,27 @@ export class DocumentDetailComponent
})
}
+ createWarehouse(newName: string) {
+ var modal = this.modalService.open(WarehouseEditDialogComponent, {
+ backdrop: 'static',
+ })
+ modal.componentInstance.dialogMode = EditDialogMode.CREATE
+ if (newName) modal.componentInstance.object = { name: newName }
+ modal.componentInstance.succeeded
+ .pipe(
+ switchMap((newWarehouse) => {
+ return this.warehouseService
+ .listAll()
+ .pipe(map((warehouses) => ({ newWarehouse, warehouses })))
+ })
+ )
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe(({ newWarehouse, warehouses }) => {
+ this.warehouses = warehouses.results
+ this.documentForm.get('warehouses').setValue(newWarehouse.id)
+ })
+ }
+
discard() {
this.documentsService
.get(this.documentId)
@@ -968,6 +1009,12 @@ export class DocumentDetailComponent
rule_type: FILTER_STORAGE_PATH,
value: (i as StoragePath).id.toString(),
}
+ } else if (i.hasOwnProperty('type')) {
+ // Warehouse
+ return {
+ rule_type: FILTER_WAREHOUSE,
+ value: (i as Warehouse).id.toString(),
+ }
} else if (i.hasOwnProperty('is_inbox_tag')) {
// Tag
return {
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
index 865502569..29131b0b0 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
@@ -74,6 +74,22 @@
(apply)="setStoragePaths($event)">
}
+ @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Warehouse)) {
+
+
+ }
+
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
index e38138df1..8c639ada6 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
@@ -24,6 +24,7 @@ import {
DocumentService,
} from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
+import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -49,9 +50,11 @@ import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path'
+import { Warehouse } from 'src/app/data/warehouse'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { WarehouseEditDialogComponent } from '../../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
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'
@@ -82,6 +85,7 @@ describe('BulkEditorComponent', () => {
let correspondentsService: CorrespondentService
let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService
+ let warehouseService: WarehouseService
let httpTestingController: HttpTestingController
beforeEach(async () => {
@@ -148,6 +152,18 @@ describe('BulkEditorComponent', () => {
}),
},
},
+ {
+ provide: WarehouseService,
+ useValue: {
+ listAll: () =>
+ of({
+ results: [
+ { id: 88, name: 'warehouse88' },
+ { id: 77, name: 'warehouse77' },
+ ],
+ }),
+ },
+ },
FilterPipe,
SettingsService,
{
@@ -189,6 +205,7 @@ describe('BulkEditorComponent', () => {
correspondentsService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService)
+ warehouseService = TestBed.inject(WarehouseService)
httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(BulkEditorComponent)
@@ -262,6 +279,22 @@ describe('BulkEditorComponent', () => {
expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
})
+ it('should apply selection data to warehouse menu', () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ fixture.detectChanges()
+ expect(
+ component.warehousesSelectionModel.getSelectedItems()
+ ).toHaveLength(0)
+ jest
+ .spyOn(documentListViewService, 'selected', 'get')
+ .mockReturnValue(new Set([3, 5, 7]))
+ jest
+ .spyOn(documentService, 'getSelectionData')
+ .mockReturnValue(of(selectionData))
+ component.openWarehouseDropdown()
+ expect(component.warehousesSelectionModel.selectionSize()).toEqual(1)
+ })
+
it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
@@ -679,6 +712,105 @@ describe('BulkEditorComponent', () => {
)
})
+ it('should execute modify warehouse 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.setWarehouses({
+ itemsToAdd: [{ id: 101 }],
+ itemsToRemove: [],
+ })
+ let req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}documents/bulk_edit/`
+ )
+ req.flush(true)
+ expect(req.request.body).toEqual({
+ documents: [3, 4],
+ method: 'set_warehouse',
+ parameters: { warehouse: 101 },
+ })
+ 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 warehouse 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.setWarehouses({
+ itemsToAdd: [{ id: 101 }],
+ itemsToRemove: [],
+ })
+ 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
+ })
+
+ it('should set modal dialog text accordingly for warehouse 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.setWarehouses({
+ itemsToAdd: [],
+ itemsToRemove: [{ id: 101, name: 'Warehouse 101' }],
+ })
+ expect(modal.componentInstance.message).toEqual(
+ 'This operation will remove the warehouse from 2 selected document(s).'
+ )
+ modal.close()
+ component.setWarehouses({
+ itemsToAdd: [{ id: 101, name: 'Warehouse 101' }],
+ itemsToRemove: [],
+ })
+ expect(modal.componentInstance.message).toEqual(
+ 'This operation will assign the storage path "Warehouse 101" to 2 selected document(s).'
+ )
+ })
+
+
it('should only execute bulk operations when changes are detected', () => {
component.setTags({
itemsToAdd: [],
@@ -696,6 +828,10 @@ describe('BulkEditorComponent', () => {
itemsToAdd: [],
itemsToRemove: [],
})
+ component.setWarehouses({
+ itemsToAdd: [],
+ itemsToRemove: [],
+ })
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
@@ -988,6 +1124,7 @@ describe('BulkEditorComponent', () => {
expect(component.correspondents).toBeUndefined()
expect(component.documentTypes).toBeUndefined()
expect(component.storagePaths).toBeUndefined()
+ expect(component.warehouses).toBeUndefined()
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/correspondents/`
@@ -998,6 +1135,9 @@ describe('BulkEditorComponent', () => {
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/storage_paths/`
)
+ httpTestingController.expectNone(
+ `${environment.apiBaseUrl}documents/warehouses/`
+ )
})
it('should support create new tag', () => {
@@ -1175,4 +1315,48 @@ describe('BulkEditorComponent', () => {
)
expect(component.storagePaths).toEqual(storagePaths.results)
})
+
+ it('should support create new warehouse', () => {
+ const name = 'New Warehouse'
+ const newWarehouse = { id: 101, name: 'New Warehouse' }
+ const warehouses: Results
= {
+ results: [
+ { id: 1, name: 'Warehouse 1' },
+ { id: 2, name: 'Warehouse 2' },
+ ],
+ count: 2,
+ all: [1, 2],
+ }
+
+ const modalInstance = {
+ componentInstance: {
+ dialogMode: EditDialogMode.CREATE,
+ object: { name },
+ succeeded: of(newWarehouse),
+ },
+ }
+ const warehousesListAllSpy = jest.spyOn(warehouseService, 'listAll')
+ warehousesListAllSpy.mockReturnValue(of(warehouses))
+
+ const warehousesSelectionModelToggleSpy = jest.spyOn(
+ component.warehousesSelectionModel,
+ 'toggle'
+ )
+
+ const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
+ modalServiceOpenSpy.mockReturnValue(modalInstance as any)
+
+ component.createWarehouse(name)
+
+ expect(modalServiceOpenSpy).toHaveBeenCalledWith(
+ WarehouseEditDialogComponent,
+ { backdrop: 'static' }
+ )
+ expect(warehousesListAllSpy).toHaveBeenCalled()
+
+ expect(warehousesSelectionModelToggleSpy).toHaveBeenCalledWith(
+ newWarehouse.id
+ )
+ expect(component.warehouses).toEqual(warehouses.results)
+ })
})
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
index 556a1ff13..f6fc255d5 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -24,6 +24,8 @@ import { ToastService } from 'src/app/services/toast.service'
import { saveAs } from 'file-saver'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { StoragePath } from 'src/app/data/storage-path'
+import { WarehouseService } from 'src/app/services/rest/warehouse.service'
+import { Warehouse } from 'src/app/data/warehouse'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
@@ -39,6 +41,7 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
+import { WarehouseEditDialogComponent } from '../../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-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'
@@ -55,15 +58,19 @@ export class BulkEditorComponent
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
+ warehouses: Warehouse[]
+
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
+ warehousesSelectionModel = new FilterableDropdownSelectionModel()
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
+ warehouseDocumentCounts: SelectionDataItem[]
awaitingDownload: boolean
unsubscribeNotifier: Subject = new Subject()
@@ -85,6 +92,7 @@ export class BulkEditorComponent
private settings: SettingsService,
private toastService: ToastService,
private storagePathService: StoragePathService,
+ private warehouseService: WarehouseService,
private permissionService: PermissionsService
) {
super()
@@ -166,6 +174,17 @@ export class BulkEditorComponent
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
}
+ if (
+ this.permissionService.currentUserCan(
+ PermissionAction.View,
+ PermissionType.Warehouse
+ )
+ ) {
+ this.warehouseService
+ .listAll()
+ .pipe(first())
+ .subscribe((result) => (this.warehouses = result.results))
+ }
this.downloadForm
.get('downloadFileTypeArchive')
@@ -297,6 +316,19 @@ export class BulkEditorComponent
})
}
+ openWarehouseDropdown() {
+ this.documentService
+ .getSelectionData(Array.from(this.list.selected))
+ .pipe(first())
+ .subscribe((s) => {
+ this.warehouseDocumentCounts = s.selected_warehouses
+ this.applySelectionData(
+ s.selected_warehouses,
+ this.warehousesSelectionModel
+ )
+ })
+ }
+
private _localizeList(items: MatchingModel[]) {
if (items.length == 0) {
return ''
@@ -495,6 +527,44 @@ export class BulkEditorComponent
}
}
+ setWarehouses(changedDocumentPaths: ChangedItems) {
+ if (
+ changedDocumentPaths.itemsToAdd.length == 0 &&
+ changedDocumentPaths.itemsToRemove.length == 0
+ )
+ return
+
+ let warehouse =
+ changedDocumentPaths.itemsToAdd.length > 0
+ ? changedDocumentPaths.itemsToAdd[0]
+ : null
+
+ if (this.showConfirmationDialogs) {
+ let modal = this.modalService.open(ConfirmDialogComponent, {
+ backdrop: 'static',
+ })
+ modal.componentInstance.title = $localize`Confirm warehouse assignment`
+ if (warehouse) {
+ modal.componentInstance.message = $localize`This operation will assign the warehouse "${warehouse.name}" to ${this.list.selected.size} selected document(s).`
+ } else {
+ modal.componentInstance.message = $localize`This operation will remove the warehouse from ${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, 'set_warehouse', {
+ warehouse: warehouse ? warehouse.id : null,
+ })
+ })
+ } else {
+ this.executeBulkOperation(null, 'set_warehouse', {
+ warehouse: warehouse ? warehouse.id : null,
+ })
+ }
+ }
+
createTag(name: string) {
let modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static',
@@ -581,6 +651,27 @@ export class BulkEditorComponent
})
}
+ createWarehouse(name: string) {
+ let modal = this.modalService.open(WarehouseEditDialogComponent, {
+ backdrop: 'static',
+ })
+ modal.componentInstance.dialogMode = EditDialogMode.CREATE
+ modal.componentInstance.object = { name }
+ modal.componentInstance.succeeded
+ .pipe(
+ switchMap((newWarehouse) => {
+ return this.warehouseService
+ .listAll()
+ .pipe(map((warehouses) => ({ newWarehouse, warehouses })))
+ })
+ )
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe(({ newWarehouse, warehouses }) => {
+ this.warehouses = warehouses.results
+ this.warehousesSelectionModel.toggle(newWarehouse.id)
+ })
+ }
+
applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
diff --git a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html
index 81489a40a..3f55e9336 100644
--- a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html
+++ b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html
@@ -83,6 +83,12 @@
{{(document.storage_path$ | async)?.name}}
}
+ @if (document.warehouses) {
+
+ }
@if (document.archive_serial_number | isNumber) {
#{{document.archive_serial_number}}
diff --git a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts
index 442114767..674e6fbbd 100644
--- a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts
+++ b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts
@@ -53,6 +53,9 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
@Output()
clickStoragePath = new EventEmitter
()
+ @Output()
+ clickWarehouse = new EventEmitter()
+
@Output()
clickMoreLike = new EventEmitter()
diff --git a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html
index ea9ba9914..d097aa78d 100644
--- a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html
+++ b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html
@@ -54,6 +54,13 @@
{{(document.storage_path$ | async)?.name ?? privateName}}
}
+ @if (document.warehouses) {
+
+ }
diff --git a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts
index 2ca1a3408..aea577a27 100644
--- a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts
+++ b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts
@@ -50,6 +50,9 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
@Output()
clickStoragePath = new EventEmitter
()
+ @Output()
+ clickWarehouse = new EventEmitter()
+
moreTags: number = null
@ViewChild('popover') popover: NgbPopover
diff --git a/src-ui/src/app/components/document-list/document-list.component.html b/src-ui/src/app/components/document-list/document-list.component.html
index 3cce1496b..bb9513559 100644
--- a/src-ui/src/app/components/document-list/document-list.component.html
+++ b/src-ui/src/app/components/document-list/document-list.component.html
@@ -192,6 +192,15 @@
(sort)="onSort($event)"
i18n>Storage path
}
+ @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Warehouse)) {
+ Warehouse |
+ }
}
+ @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Warehouse)) {
+ |
+ @if (d.warehouses) {
+ {{(d.warehouses$ | async)?.name}}
+ }
+ |
+ }
{{d.created_date | customDate}}
|
diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts
index 77dc03f84..5f6478e21 100644
--- a/src-ui/src/app/components/document-list/document-list.component.spec.ts
+++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts
@@ -588,6 +588,7 @@ describe('DocumentListComponent', () => {
component.clickCorrespondent(2)
component.clickDocumentType(3)
component.clickStoragePath(4)
+ component.clickWarehouse(5)
})
it('should support quick filter on document more like', () => {
diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts
index 7d27f4e3e..2a756e874 100644
--- a/src-ui/src/app/components/document-list/document-list.component.ts
+++ b/src-ui/src/app/components/document-list/document-list.component.ts
@@ -291,6 +291,11 @@ export class DocumentListComponent
this.filterEditor.toggleStoragePath(storagePathID)
}
+ clickWarehouse(warehouseID: number) {
+ this.list.selectNone()
+ this.filterEditor.toggleWarehouse(warehouseID)
+ }
+
clickMoreLike(documentID: number) {
this.list.quickFilter([
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() },
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
index 89900e087..5e7389016 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
@@ -70,6 +70,16 @@
[documentCounts]="storagePathDocumentCounts"
[allowSelectNone]="true">
}
+ @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Warehouse)) {
+
+ }
{
listAll: () => of({ results: storage_paths }),
},
},
+ {
+ provide: WarehouseService,
+ useValue: {
+ listAll: () => of({ results: warehouses }),
+ },
+ },
{
provide: UserService,
useValue: {
@@ -806,6 +828,89 @@ describe('FilterEditorComponent', () => {
]
}))
+ it('should ingest filter rules for has warehouse', fakeAsync(() => {
+ expect(component.warehouseSelectionModel.getSelectedItems()).toHaveLength(
+ 0
+ )
+ component.filterRules = [
+ {
+ rule_type: FILTER_WAREHOUSE,
+ value: '42',
+ },
+ ]
+ expect(component.warehouseSelectionModel.logicalOperator).toEqual(
+ LogicalOperator.Or
+ )
+ expect(component.warehouseSelectionModel.intersection).toEqual(
+ Intersection.Include
+ )
+ expect(component.warehouseSelectionModel.getSelectedItems()).toEqual([
+ warehouses[0],
+ ])
+ component.toggleWarehouse(42) // coverage
+ }))
+
+ it('should ingest filter rules for has any of warehouse', fakeAsync(() => {
+ expect(component.warehouseSelectionModel.getSelectedItems()).toHaveLength(
+ 0
+ )
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_WAREHOUSE_ANY,
+ value: '42',
+ },
+ {
+ rule_type: FILTER_HAS_WAREHOUSE_ANY,
+ value: '43',
+ },
+ ]
+ expect(component.warehouseSelectionModel.logicalOperator).toEqual(
+ LogicalOperator.Or
+ )
+ expect(component.warehouseSelectionModel.intersection).toEqual(
+ Intersection.Include
+ )
+ expect(component.warehouseSelectionModel.getSelectedItems()).toEqual(
+ warehouses
+ )
+ // coverage
+ component.filterRules = [
+ {
+ rule_type: FILTER_HAS_WAREHOUSE_ANY,
+ value: null,
+ },
+ ]
+ }))
+
+ it('should ingest filter rules for does not have any of warehouses', fakeAsync(() => {
+ expect(component.warehouseSelectionModel.getExcludedItems()).toHaveLength(
+ 0
+ )
+ component.filterRules = [
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
+ value: '42',
+ },
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
+ value: '43',
+ },
+ ]
+ expect(component.warehouseSelectionModel.intersection).toEqual(
+ Intersection.Exclude
+ )
+ expect(component.warehouseSelectionModel.getExcludedItems()).toEqual(
+ warehouses
+ )
+ // coverage
+ component.filterRules = [
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
+ value: null,
+ },
+ ]
+ }))
+
it('should ingest filter rules for owner', fakeAsync(() => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
@@ -1317,6 +1422,63 @@ describe('FilterEditorComponent', () => {
])
}))
+ it('should convert user input to correct filter rules on warehouse selections', fakeAsync(() => {
+ const warehouseFilterableDropdown = fixture.debugElement.queryAll(
+ By.directive(FilterableDropdownComponent)
+ )[4] // Warehouse dropdown
+ warehouseFilterableDropdown.triggerEventHandler('opened')
+ const warehouseButtons = warehouseFilterableDropdown.queryAll(
+ By.directive(ToggleableDropdownButtonComponent)
+ )
+ warehouseButtons[1].triggerEventHandler('toggle')
+ warehouseButtons[2].triggerEventHandler('toggle')
+ fixture.detectChanges()
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_HAS_WAREHOUSE_ANY,
+ value: warehouses[0].id.toString(),
+ },
+ {
+ rule_type: FILTER_HAS_WAREHOUSE_ANY,
+ value: warehouses[1].id.toString(),
+ },
+ ])
+ const toggleIntersectionButtons = warehouseFilterableDropdown.queryAll(
+ By.css('input[type=radio]')
+ )
+ toggleIntersectionButtons[1].nativeElement.checked = true
+ toggleIntersectionButtons[1].triggerEventHandler('change')
+ fixture.detectChanges()
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
+ value: warehouses[0].id.toString(),
+ },
+ {
+ rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
+ value: warehouses[1].id.toString(),
+ },
+ ])
+ }))
+
+ it('should convert user input to correct filter rules on warehouse select not assigned', fakeAsync(() => {
+ const warehousesFilterableDropdown = fixture.debugElement.queryAll(
+ By.directive(FilterableDropdownComponent)
+ )[4]
+ warehousesFilterableDropdown.triggerEventHandler('opened')
+ const notAssignedButton = warehousesFilterableDropdown.queryAll(
+ By.directive(ToggleableDropdownButtonComponent)
+ )[0]
+ notAssignedButton.triggerEventHandler('toggle')
+ fixture.detectChanges()
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_WAREHOUSE,
+ value: null,
+ },
+ ])
+ }))
+
it('should convert user input to correct filter rules on date created after', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
index a6aafe049..1eb6162b4 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
@@ -11,10 +11,12 @@ import {
import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
+import { Warehouse } from 'src/app/data/warehouse'
import { Subject, Subscription } from 'rxjs'
import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { TagService } from 'src/app/services/rest/tag.service'
+import { WarehouseService } from 'src/app/services/rest/warehouse.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { FilterRule } from 'src/app/data/filter-rule'
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
@@ -35,15 +37,18 @@ import {
FILTER_TITLE,
FILTER_TITLE_CONTENT,
FILTER_HAS_STORAGE_PATH_ANY,
+ FILTER_HAS_WAREHOUSE_ANY,
FILTER_ASN_ISNULL,
FILTER_ASN_GT,
FILTER_ASN_LT,
FILTER_DOES_NOT_HAVE_CORRESPONDENT,
FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
FILTER_DOES_NOT_HAVE_STORAGE_PATH,
+ FILTER_DOES_NOT_HAVE_WAREHOUSE,
FILTER_DOCUMENT_TYPE,
FILTER_CORRESPONDENT,
FILTER_STORAGE_PATH,
+ FILTER_WAREHOUSE,
FILTER_OWNER,
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
@@ -188,6 +193,16 @@ export class FilterEditorComponent
} else {
return $localize`Without document type`
}
+
+ case FILTER_WAREHOUSE:
+ case FILTER_HAS_WAREHOUSE_ANY:
+ if (rule.value) {
+ return $localize`Warehouse: ${this.warehouses.find(
+ (w) => w.id == +rule.value
+ )?.name}`
+ } else {
+ return $localize`Without warehouse`
+ }
case FILTER_STORAGE_PATH:
case FILTER_HAS_STORAGE_PATH_ANY:
@@ -231,6 +246,7 @@ export class FilterEditorComponent
constructor(
private documentTypeService: DocumentTypeService,
private tagService: TagService,
+ private warehouseService: WarehouseService,
private correspondentService: CorrespondentService,
private documentService: DocumentService,
private storagePathService: StoragePathService,
@@ -246,11 +262,13 @@ export class FilterEditorComponent
correspondents: Correspondent[] = []
documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = []
+ warehouses: Warehouse[] = []
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
+ warehouseDocumentCounts: SelectionDataItem[]
_textFilter = ''
_moreLikeId: number
@@ -288,6 +306,8 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
+ warehouseSelectionModel = new FilterableDropdownSelectionModel()
+
dateCreatedBefore: string
dateCreatedAfter: string
@@ -320,6 +340,7 @@ export class FilterEditorComponent
this.documentTypeSelectionModel.clear(false)
this.storagePathSelectionModel.clear(false)
+ this.warehouseSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
this._textFilter = null
@@ -470,6 +491,24 @@ export class FilterEditorComponent
false
)
break
+ case FILTER_WAREHOUSE:
+ case FILTER_HAS_WAREHOUSE_ANY:
+ this.warehouseSelectionModel.logicalOperator = LogicalOperator.Or
+ this.warehouseSelectionModel.intersection = Intersection.Include
+ this.warehouseSelectionModel.set(
+ rule.value ? +rule.value : null,
+ ToggleableItemState.Selected,
+ false
+ )
+ break
+ case FILTER_DOES_NOT_HAVE_WAREHOUSE:
+ this.warehouseSelectionModel.intersection = Intersection.Exclude
+ this.warehouseSelectionModel.set(
+ rule.value ? +rule.value : null,
+ ToggleableItemState.Excluded,
+ false
+ )
+ break
case FILTER_STORAGE_PATH:
case FILTER_HAS_STORAGE_PATH_ANY:
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
@@ -683,6 +722,26 @@ export class FilterEditorComponent
})
})
}
+ if (this.warehouseSelectionModel.isNoneSelected()) {
+ filterRules.push({ rule_type: FILTER_WAREHOUSE, value: null })
+ } else {
+ this.warehouseSelectionModel
+ .getSelectedItems()
+ .forEach((warehouse) => {
+ filterRules.push({
+ rule_type: FILTER_HAS_WAREHOUSE_ANY,
+ value: warehouse.id?.toString(),
+ })
+ })
+ this.warehouseSelectionModel
+ .getExcludedItems()
+ .forEach((warehouse) => {
+ filterRules.push({
+ rule_type: FILTER_DOES_NOT_HAVE_WAREHOUSE,
+ value: warehouse.id?.toString(),
+ })
+ })
+ }
if (this.storagePathSelectionModel.isNoneSelected()) {
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
} else {
@@ -845,6 +904,8 @@ export class FilterEditorComponent
selectionData?.selected_correspondents ?? null
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? null
+ this.warehouseDocumentCounts =
+ selectionData?.selected_warehouses ?? null
}
rulesModified: boolean = false
@@ -895,6 +956,16 @@ export class FilterEditorComponent
.listAll()
.subscribe((result) => (this.documentTypes = result.results))
}
+ if (
+ this.permissionsService.currentUserCan(
+ PermissionAction.View,
+ PermissionType.Warehouse
+ )
+ ) {
+ this.warehouseService
+ .listAll()
+ .subscribe((result) => (this.warehouses = result.results))
+ }
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
@@ -941,6 +1012,10 @@ export class FilterEditorComponent
this.documentTypeSelectionModel.toggle(documentTypeId)
}
+ toggleWarehouse(warehouseId: number) {
+ this.warehouseSelectionModel.toggle(warehouseId)
+ }
+
toggleStoragePath(storagePathID: number) {
this.storagePathSelectionModel.toggle(storagePathID)
}
@@ -957,6 +1032,10 @@ export class FilterEditorComponent
this.documentTypeSelectionModel.apply()
}
+ onWarehouseDropdownOpen() {
+ this.warehouseSelectionModel.apply()
+ }
+
onStoragePathDropdownOpen() {
this.storagePathSelectionModel.apply()
}
diff --git a/src-ui/src/app/components/manage/warehouse-list/warehouse-list.component.spec.ts b/src-ui/src/app/components/manage/warehouse-list/warehouse-list.component.spec.ts
new file mode 100644
index 000000000..39e506399
--- /dev/null
+++ b/src-ui/src/app/components/manage/warehouse-list/warehouse-list.component.spec.ts
@@ -0,0 +1,72 @@
+import { DatePipe } from '@angular/common'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
+import { of } from 'rxjs'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { SortableDirective } from 'src/app/directives/sortable.directive'
+import { WarehouseService } from 'src/app/services/rest/warehouse.service'
+import { PageHeaderComponent } from '../../common/page-header/page-header.component'
+import { WarehouseListComponent } from './warehouse-list.component'
+import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+
+describe('WarehouseListComponent', () => {
+ let component: WarehouseListComponent
+ let fixture: ComponentFixture
+ let warehouseService: WarehouseService
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ declarations: [
+ WarehouseListComponent,
+ SortableDirective,
+ PageHeaderComponent,
+ IfPermissionsDirective,
+ SafeHtmlPipe,
+ ],
+ providers: [DatePipe],
+ imports: [
+ HttpClientTestingModule,
+ NgbPaginationModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgxBootstrapIconsModule.pick(allIcons),
+ ],
+ }).compileComponents()
+
+ warehouseService = TestBed.inject(WarehouseService)
+ jest.spyOn(warehouseService, 'listFiltered').mockReturnValue(
+ of({
+ count: 3,
+ all: [1, 2, 3],
+ results: [
+ {
+ id: 1,
+ name: 'Warehouse1',
+ },
+ {
+ id: 2,
+ name: 'Warehouse2',
+ },
+ {
+ id: 3,
+ name: 'Warehouse3',
+ },
+ ],
+ })
+ )
+ fixture = TestBed.createComponent(WarehouseListComponent)
+ component = fixture.componentInstance
+ fixture.detectChanges()
+ })
+
+ // Tests are included in management-list.component.spec.ts
+
+ it('should use correct delete message', () => {
+ expect(component.getDeleteMessage({ id: 1, name: 'Warehouse1' })).toEqual(
+ 'Do you really want to delete the warehouse "Warehouse1"?'
+ )
+ })
+})
diff --git a/src-ui/src/app/components/manage/warehouse-list/warehouse-list.component.ts b/src-ui/src/app/components/manage/warehouse-list/warehouse-list.component.ts
new file mode 100644
index 000000000..b721fc7ac
--- /dev/null
+++ b/src-ui/src/app/components/manage/warehouse-list/warehouse-list.component.ts
@@ -0,0 +1,55 @@
+import { Component } from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
+import { Warehouse } from 'src/app/data/warehouse'
+import { DocumentListViewService } from 'src/app/services/document-list-view.service'
+import {
+ PermissionsService,
+ PermissionType,
+} from 'src/app/services/permissions.service'
+import { WarehouseService } from 'src/app/services/rest/warehouse.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { WarehouseEditDialogComponent } from '../../common/edit-dialog/warehouse-edit-dialog/warehouse-edit-dialog.component'
+import { ManagementListComponent } from '../management-list/management-list.component'
+
+@Component({
+ selector: 'pngx-warehouse-list',
+ templateUrl: './../management-list/management-list.component.html',
+ styleUrls: ['./../management-list/management-list.component.scss'],
+})
+export class WarehouseListComponent extends ManagementListComponent {
+ constructor(
+ warehouseService: WarehouseService,
+ modalService: NgbModal,
+ toastService: ToastService,
+ documentListViewService: DocumentListViewService,
+ permissionsService: PermissionsService
+ ) {
+ super(
+ warehouseService,
+ modalService,
+ WarehouseEditDialogComponent,
+ toastService,
+ documentListViewService,
+ permissionsService,
+ FILTER_HAS_TAGS_ALL,
+ $localize`warehouse`,
+ $localize`warehouses`,
+ PermissionType.Warehouse,
+ [
+ {
+ key: 'type',
+ name: $localize`Type`,
+ rendersHtml: true,
+ valueFn: (w: Warehouse) => {
+ return w.type
+ },
+ },
+ ]
+ )
+ }
+
+ getDeleteMessage(object: Warehouse) {
+ return $localize`Do you really want to delete the warehouse "${object.name}"?`
+ }
+}
diff --git a/src-ui/src/app/data/document-suggestions.ts b/src-ui/src/app/data/document-suggestions.ts
index 85f9f9d22..a1165fee9 100644
--- a/src-ui/src/app/data/document-suggestions.ts
+++ b/src-ui/src/app/data/document-suggestions.ts
@@ -7,5 +7,7 @@ export interface DocumentSuggestions {
storage_paths?: number[]
+ warehouses?: number[]
+
dates?: string[] // ISO-formatted date string e.g. 2022-11-03
}
diff --git a/src-ui/src/app/data/document.ts b/src-ui/src/app/data/document.ts
index 910666f10..4b249fc7e 100644
--- a/src-ui/src/app/data/document.ts
+++ b/src-ui/src/app/data/document.ts
@@ -3,6 +3,7 @@ import { Tag } from './tag'
import { DocumentType } from './document-type'
import { Observable } from 'rxjs'
import { StoragePath } from './storage-path'
+import { Warehouse } from './warehouse'
import { ObjectWithPermissions } from './object-with-permissions'
import { DocumentNote } from './document-note'
import { CustomFieldInstance } from './custom-field-instance'
@@ -28,6 +29,10 @@ export interface Document extends ObjectWithPermissions {
storage_path?: number
+ warehouses$?: Observable
+
+ warehouses?: number
+
title?: string
content?: string
diff --git a/src-ui/src/app/data/filter-rule-type.ts b/src-ui/src/app/data/filter-rule-type.ts
index ee09f165d..4accfa68c 100644
--- a/src-ui/src/app/data/filter-rule-type.ts
+++ b/src-ui/src/app/data/filter-rule-type.ts
@@ -49,6 +49,10 @@ export const FILTER_SHARED_BY_USER = 37
export const FILTER_CUSTOM_FIELDS = 36
+export const FILTER_WAREHOUSE = 50
+export const FILTER_HAS_WAREHOUSE_ANY = 51
+export const FILTER_DOES_NOT_HAVE_WAREHOUSE = 52
+
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
id: FILTER_TITLE,
@@ -108,6 +112,25 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'storage_path',
multi: true,
},
+ {
+ id: FILTER_WAREHOUSE,
+ filtervar: 'warehouses__id',
+ isnull_filtervar: 'warehouses__isnull',
+ datatype: 'warehouse',
+ multi: false,
+ },
+ {
+ id: FILTER_HAS_WAREHOUSE_ANY,
+ filtervar: 'warehouses__id__in',
+ datatype: 'warehouse',
+ multi: true,
+ },
+ {
+ id: FILTER_DOES_NOT_HAVE_WAREHOUSE,
+ filtervar: 'warehouses__id__none',
+ datatype: 'warehouse',
+ multi: true,
+ },
{
id: FILTER_DOCUMENT_TYPE,
filtervar: 'document_type__id',
diff --git a/src-ui/src/app/data/warehouse.ts b/src-ui/src/app/data/warehouse.ts
new file mode 100644
index 000000000..27b936df0
--- /dev/null
+++ b/src-ui/src/app/data/warehouse.ts
@@ -0,0 +1,8 @@
+import { MatchingModel } from './matching-model'
+
+export interface Warehouse extends MatchingModel {
+
+ type?: string
+
+ parent_warehouse?: number
+}
diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts
index ff64d19b3..77918c96c 100644
--- a/src-ui/src/app/data/workflow-action.ts
+++ b/src-ui/src/app/data/workflow-action.ts
@@ -17,6 +17,8 @@ export interface WorkflowAction extends ObjectWithId {
assign_storage_path?: number // StoragePath.id
+ assign_warehouses?: number // Warehouse.id
+
assign_owner?: number // User.id
assign_view_users?: number[] // [User.id]
@@ -45,6 +47,10 @@ export interface WorkflowAction extends ObjectWithId {
remove_all_storage_paths?: boolean
+ remove_warehouses?: number[] // [Warehouse.id]
+
+ remove_all_warehouses?: boolean
+
remove_owners?: number[] // [User.id]
remove_all_owners?: boolean
diff --git a/src-ui/src/app/services/permissions.service.spec.ts b/src-ui/src/app/services/permissions.service.spec.ts
index f4e01945e..9f4929f6d 100644
--- a/src-ui/src/app/services/permissions.service.spec.ts
+++ b/src-ui/src/app/services/permissions.service.spec.ts
@@ -264,6 +264,10 @@ describe('PermissionsService', () => {
'change_applicationconfiguration',
'delete_applicationconfiguration',
'view_applicationconfiguration',
+ 'change_warehouse',
+ 'view_warehouse',
+ 'add_warehouse',
+ 'delete_warehouse',
],
{
username: 'testuser',
diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts
index 0648f461f..199836b73 100644
--- a/src-ui/src/app/services/permissions.service.ts
+++ b/src-ui/src/app/services/permissions.service.ts
@@ -12,6 +12,7 @@ export enum PermissionAction {
export enum PermissionType {
Document = '%s_document',
Tag = '%s_tag',
+ Warehouse = '%s_warehouse',
Correspondent = '%s_correspondent',
DocumentType = '%s_documenttype',
StoragePath = '%s_storagepath',
diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts
index 5c0f0a1dc..3c4f3cf8a 100644
--- a/src-ui/src/app/services/rest/document.service.ts
+++ b/src-ui/src/app/services/rest/document.service.ts
@@ -13,6 +13,8 @@ import { TagService } from './tag.service'
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { queryParamsFromFilterRules } from '../../utils/query-params'
import { StoragePathService } from './storage-path.service'
+import { WarehouseService } from './warehouse.service'
+
import {
PermissionAction,
PermissionType,
@@ -26,6 +28,7 @@ export const DOCUMENT_SORT_FIELDS = [
{ field: 'correspondent__name', name: $localize`Correspondent` },
{ field: 'title', name: $localize`Title` },
{ field: 'document_type__name', name: $localize`Document type` },
+ { field: 'warehouses__name', name: $localize`Warehouse` },
{ field: 'created', name: $localize`Created` },
{ field: 'added', name: $localize`Added` },
{ field: 'modified', name: $localize`Modified` },
@@ -51,6 +54,8 @@ export interface SelectionData {
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
+ selected_warehouses: SelectionDataItem[]
+
}
@Injectable({
@@ -65,6 +70,7 @@ export class DocumentService extends AbstractPaperlessService {
private documentTypeService: DocumentTypeService,
private tagService: TagService,
private storagePathService: StoragePathService,
+ private warehouseService: WarehouseService,
private permissionsService: PermissionsService,
private settingsService: SettingsService
) {
@@ -116,6 +122,15 @@ export class DocumentService extends AbstractPaperlessService {
) {
doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
}
+ if (
+ doc.warehouses &&
+ this.permissionsService.currentUserCan(
+ PermissionAction.View,
+ PermissionType.Warehouse
+ )
+ ) {
+ doc.warehouses$ = this.warehouseService.getCached(doc.warehouses)
+ }
return doc
}
diff --git a/src-ui/src/app/services/rest/warehouse.service.spec.ts b/src-ui/src/app/services/rest/warehouse.service.spec.ts
new file mode 100644
index 000000000..89386369d
--- /dev/null
+++ b/src-ui/src/app/services/rest/warehouse.service.spec.ts
@@ -0,0 +1,4 @@
+import { WarehouseService } from './warehouse.service'
+import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
+
+commonAbstractNameFilterPaperlessServiceTests('warehouses', WarehouseService)
diff --git a/src-ui/src/app/services/rest/warehouse.service.ts b/src-ui/src/app/services/rest/warehouse.service.ts
new file mode 100644
index 000000000..682c4b80b
--- /dev/null
+++ b/src-ui/src/app/services/rest/warehouse.service.ts
@@ -0,0 +1,13 @@
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { Warehouse } from 'src/app/data/warehouse'
+import { AbstractNameFilterService } from './abstract-name-filter-service'
+
+@Injectable({
+ providedIn: 'root',
+})
+export class WarehouseService extends AbstractNameFilterService {
+ constructor(http: HttpClient) {
+ super(http, 'warehouses')
+ }
+}
diff --git a/src/documents/filters.py b/src/documents/filters.py
index b75f9c9c4..bf55db3ec 100644
--- a/src/documents/filters.py
+++ b/src/documents/filters.py
@@ -191,6 +191,8 @@ class DocumentFilterSet(FilterSet):
document_type__id__none = ObjectFilter(field_name="document_type", exclude=True)
storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True)
+
+ warehouses__id__none = ObjectFilter(field_name="warehouses", exclude=True)
is_in_inbox = InboxFilter()
@@ -225,6 +227,9 @@ class DocumentFilterSet(FilterSet):
"storage_path": ["isnull"],
"storage_path__id": ID_KWARGS,
"storage_path__name": CHAR_KWARGS,
+ "warehouses": ["isnull"],
+ "warehouses__id": ID_KWARGS,
+ "warehouses__name": CHAR_KWARGS,
"owner": ["isnull"],
"owner__id": ID_KWARGS,
"custom_fields": ["icontains"],
diff --git a/src/documents/migrations/1047_warehouse.py b/src/documents/migrations/1047_warehouse.py
deleted file mode 100644
index 1ac590460..000000000
--- a/src/documents/migrations/1047_warehouse.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# Generated by Django 4.2.11 on 2024-05-15 04:18
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('documents', '1046_workflowaction_remove_all_correspondents_and_more'),
- ]
-
- operations = [
- migrations.CreateModel(
- name='Warehouse',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(max_length=256, unique=True, verbose_name='name')),
- ('type', models.CharField(blank=True, choices=[(1, 'Warehouse'), (2, 'Shelf'), (3, 'Boxcase')], default=1, max_length=20, null=True)),
- ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='owner')),
- ('parent_warehouse', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_warehouses', to='documents.warehouse')),
- ],
- options={
- 'verbose_name': 'warehouse',
- 'verbose_name_plural': 'warehouses',
- },
- ),
- ]
diff --git a/src/documents/migrations/1048_alter_warehouse_parent_warehouse.py b/src/documents/migrations/1048_alter_warehouse_parent_warehouse.py
new file mode 100644
index 000000000..deb246a54
--- /dev/null
+++ b/src/documents/migrations/1048_alter_warehouse_parent_warehouse.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.11 on 2024-05-20 09:47
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('documents', '1047_warehouse_document_warehouses'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='warehouse',
+ name='parent_warehouse',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subwarehouses', to='documents.warehouse'),
+ ),
+ ]
diff --git a/src/documents/migrations/1049_alter_warehouse_parent_warehouse.py b/src/documents/migrations/1049_alter_warehouse_parent_warehouse.py
new file mode 100644
index 000000000..42fe11196
--- /dev/null
+++ b/src/documents/migrations/1049_alter_warehouse_parent_warehouse.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.11 on 2024-05-20 09:50
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('documents', '1048_alter_warehouse_parent_warehouse'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='warehouse',
+ name='parent_warehouse',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subwarehouse', to='documents.warehouse'),
+ ),
+ ]
diff --git a/src/documents/migrations/1050_alter_warehouse_parent_warehouse.py b/src/documents/migrations/1050_alter_warehouse_parent_warehouse.py
new file mode 100644
index 000000000..c8f708dcc
--- /dev/null
+++ b/src/documents/migrations/1050_alter_warehouse_parent_warehouse.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.11 on 2024-05-20 10:12
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('documents', '1049_alter_warehouse_parent_warehouse'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='warehouse',
+ name='parent_warehouse',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='documents.warehouse'),
+ ),
+ ]
diff --git a/src/documents/migrations/1051_alter_warehouse_options_warehouse_is_insensitive_and_more.py b/src/documents/migrations/1051_alter_warehouse_options_warehouse_is_insensitive_and_more.py
new file mode 100644
index 000000000..3f96a803c
--- /dev/null
+++ b/src/documents/migrations/1051_alter_warehouse_options_warehouse_is_insensitive_and_more.py
@@ -0,0 +1,45 @@
+# Generated by Django 4.2.11 on 2024-05-21 07:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('documents', '1050_alter_warehouse_parent_warehouse'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='warehouse',
+ options={'ordering': ('name',), 'verbose_name': 'warehouse', 'verbose_name_plural': 'warehouses'},
+ ),
+ migrations.AddField(
+ model_name='warehouse',
+ name='is_insensitive',
+ field=models.BooleanField(default=True, verbose_name='is insensitive'),
+ ),
+ migrations.AddField(
+ model_name='warehouse',
+ name='match',
+ field=models.CharField(blank=True, max_length=256, verbose_name='match'),
+ ),
+ migrations.AddField(
+ model_name='warehouse',
+ name='matching_algorithm',
+ field=models.PositiveIntegerField(choices=[(0, 'None'), (1, 'Any word'), (2, 'All words'), (3, 'Exact match'), (4, 'Regular expression'), (5, 'Fuzzy word'), (6, 'Automatic')], default=1, verbose_name='matching algorithm'),
+ ),
+ migrations.AlterField(
+ model_name='warehouse',
+ name='name',
+ field=models.CharField(max_length=128, verbose_name='name'),
+ ),
+ migrations.AddConstraint(
+ model_name='warehouse',
+ constraint=models.UniqueConstraint(fields=('name', 'owner'), name='documents_warehouse_unique_name_owner'),
+ ),
+ migrations.AddConstraint(
+ model_name='warehouse',
+ constraint=models.UniqueConstraint(condition=models.Q(('owner__isnull', True)), fields=('name',), name='documents_warehouse_name_uniq'),
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 3516ca934..515557947 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -129,7 +129,7 @@ class StoragePath(MatchingModel):
verbose_name = _("storage path")
verbose_name_plural = _("storage paths")
-class Warehouse(ModelWithOwner):
+class Warehouse(MatchingModel):
WAREHOUSE = "Warehouse"
SHELF = "Shelf"
@@ -140,13 +140,12 @@ class Warehouse(ModelWithOwner):
(BOXCASE, _("Boxcase")),
)
- name = models.CharField(_("name"), max_length=256, unique=True)
type = models.CharField(max_length=20, null=True, blank=True,
choices=TYPE_WAREHOUSE,
default=WAREHOUSE,)
- parent_warehouse = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name="parent_warehouses" )
+ parent_warehouse = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True )
- class Meta:
+ class Meta(MatchingModel.Meta):
verbose_name = _("warehouse")
verbose_name_plural = _("warehouses")
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 979f3aa9e..96ab916c7 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -1756,6 +1756,10 @@ class WorkflowSerializer(serializers.ModelSerializer):
class WarehouseSerializer(MatchingModelSerializer, OwnedObjectSerializer):
+ document_count = serializers.SerializerMethodField()
+ def get_document_count(self,obj):
+ document = Document.objects.filter(warehouses=obj).count()
+ return document
class Meta:
model = Warehouse
@@ -1763,11 +1767,18 @@ class WarehouseSerializer(MatchingModelSerializer, OwnedObjectSerializer):
def to_representation(self, instance):
data = super().to_representation(instance)
+
+ document_count = self.get_document_count(instance)
+ data['document_count'] = document_count
+
if instance.parent_warehouse:
- data['parent_warehouse'] = WarehouseSerializer(instance.parent_warehouse).data
+ parent_serializer = self.__class__(instance.parent_warehouse)
+ data['parent_warehouse'] = parent_serializer.data
+ data['parent_warehouse']['document_count'] = document_count
else:
data['parent_warehouse'] = None
- return data
+
+ return data
\ No newline at end of file
diff --git a/src/documents/views.py b/src/documents/views.py
index 8233405c9..d7995c5b3 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -336,7 +336,7 @@ class DocumentViewSet(
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = DocumentFilterSet
- search_fields = ("title", "correspondent__name", "content")
+ search_fields = ("title", "correspondent__name", "content", "warehouses")
ordering_fields = (
"id",
"title",
@@ -1519,9 +1519,18 @@ class BulkEditObjectsView(PassUserMixin):
"Error performing bulk permissions edit, check logs for more detail.",
)
+ elif operation == "delete" and object_type == "warehouses":
+
+ documents = Document.objects.filter(warehouses__in=object_ids)
+ documents.delete()
+ objs.delete()
+
elif operation == "delete":
+
objs.delete()
+
+
return Response({"result": "OK"})