+
@if (customAppTitle?.length) {
{{customAppTitle}}
diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
index 58aa029ee..fe377cc70 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
+++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
@@ -493,12 +493,17 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(changedResult.getExcludedItems()).toEqual(items)
}))
- it('FilterableDropdownSelectionModel should sort items by state', () => {
- component.items = items
+ it('selection model should sort items by state', () => {
+ component.items = items.concat([{ id: null, name: 'Null B' }])
component.selectionModel = selectionModel
selectionModel.toggle(items[1].id)
selectionModel.apply()
- expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
+ expect(selectionModel.itemsSorted).toEqual([
+ nullItem,
+ { id: null, name: 'Null B' },
+ items[1],
+ items[0],
+ ])
})
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
@@ -542,4 +547,34 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
tick(300)
expect(createSpy).toHaveBeenCalled()
}))
+
+ it('should exclude item and trigger change event', () => {
+ const id = 1
+ const state = ToggleableItemState.Selected
+ component.selectionModel = selectionModel
+ component.manyToOne = true
+ component.selectionModel.singleSelect = true
+ component.selectionModel.intersection = Intersection.Include
+ component.selectionModel['temporarySelectionStates'].set(id, state)
+ const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
+ component.selectionModel.exclude(id)
+ expect(component.selectionModel.temporaryLogicalOperator).toBe(
+ LogicalOperator.And
+ )
+ expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
+ ToggleableItemState.Excluded
+ )
+ expect(component.selectionModel['temporarySelectionStates'].size).toBe(1)
+ expect(changedSpy).toHaveBeenCalled()
+ })
+
+ it('should initialize selection states and apply changes', () => {
+ selectionModel.items = items
+ const map = new Map
()
+ map.set(1, ToggleableItemState.Selected)
+ map.set(2, ToggleableItemState.Excluded)
+ selectionModel.init(map)
+ expect(selectionModel.getSelectedItems()).toEqual([items[0]])
+ expect(selectionModel.getExcludedItems()).toEqual([items[1]])
+ })
})
diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
index bb1a9da27..4f39d32c3 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
+++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
@@ -275,7 +275,7 @@ export class FilterableDropdownSelectionModel {
)
}
- init(map) {
+ init(map: Map) {
this.temporarySelectionStates = map
this.apply()
}
diff --git a/src-ui/src/app/components/common/input/select/select.component.spec.ts b/src-ui/src/app/components/common/input/select/select.component.spec.ts
index e9eee1648..79eec16e8 100644
--- a/src-ui/src/app/components/common/input/select/select.component.spec.ts
+++ b/src-ui/src/app/components/common/input/select/select.component.spec.ts
@@ -118,4 +118,18 @@ describe('SelectComponent', () => {
tick(3000)
expect(clearSpy).toHaveBeenCalled()
}))
+
+ it('should emit filtered documents', () => {
+ component.value = 10
+ component.items = items
+ const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
+ component.onFilterDocuments()
+ expect(emitSpy).toHaveBeenCalledWith([items[2]])
+ })
+
+ it('should return the correct filter button title', () => {
+ component.title = 'Tag'
+ const expectedTitle = `Filter documents with this ${component.title}`
+ expect(component.filterButtonTitle).toEqual(expectedTitle)
+ })
})
diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
index af321ab9e..f08fed4f8 100644
--- a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
+++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts
@@ -169,4 +169,12 @@ describe('TagsComponent', () => {
expect(component.getTag(2)).toEqual(tags[1])
expect(component.getTag(4)).toBeUndefined()
})
+
+ it('should emit filtered documents', () => {
+ component.value = [10]
+ component.tags = tags
+ const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
+ component.onFilterDocuments()
+ expect(emitSpy).toHaveBeenCalledWith([tags[2]])
+ })
})
diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts
index 5ecc116c2..172239dbb 100644
--- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts
+++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts
@@ -119,6 +119,8 @@ describe('UploadFileWidgetComponent', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.WORKING
expect(component.getStatusColor(processingStatus)).toEqual('primary')
+ processingStatus.phase = FileStatusPhase.UPLOADING
+ expect(component.getStatusColor(processingStatus)).toEqual('primary')
const failedStatus = new FileStatus()
failedStatus.phase = FileStatusPhase.FAILED
expect(component.getStatusColor(failedStatus)).toEqual('danger')
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 6630f0ad6..4eae47615 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
@@ -634,11 +634,14 @@ export class DocumentDetailComponent
// in case data changed while saving eg removing inbox_tags
this.documentForm.patchValue(docValues)
this.store.next(this.documentForm.value)
+ this.openDocumentService.setDirty(this.document, false)
this.toastService.showInfo($localize`Document saved successfully.`)
- close && this.close()
this.networkActive = false
this.error = null
- this.openDocumentService.refreshDocument(this.documentId)
+ close &&
+ this.close(() =>
+ this.openDocumentService.refreshDocument(this.documentId)
+ )
},
error: (error) => {
this.networkActive = false
@@ -693,12 +696,13 @@ export class DocumentDetailComponent
})
}
- close() {
+ close(closedCallback: () => void = null) {
this.openDocumentService
.closeDocument(this.document)
.pipe(first())
.subscribe((closed) => {
if (!closed) return
+ if (closedCallback) closedCallback()
if (this.documentListViewService.activeSavedViewId) {
this.router.navigate([
'view',
diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
index 1f7874669..e091dbf15 100644
--- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
@@ -381,6 +381,28 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toBeNull()
}))
+ it('should ingest text filter content with relative dates that are not in quick list', fakeAsync(() => {
+ expect(component.dateAddedRelativeDate).toBeNull()
+ component.filterRules = [
+ {
+ rule_type: FILTER_FULLTEXT_QUERY,
+ value: 'added:[-2 week to now]',
+ },
+ ]
+ expect(component.dateAddedRelativeDate).toBeNull()
+ expect(component.textFilter).toEqual('added:[-2 week to now]')
+
+ expect(component.dateCreatedRelativeDate).toBeNull()
+ component.filterRules = [
+ {
+ rule_type: FILTER_FULLTEXT_QUERY,
+ value: 'created:[-2 week to now]',
+ },
+ ]
+ expect(component.dateCreatedRelativeDate).toBeNull()
+ expect(component.textFilter).toEqual('created:[-2 week to now]')
+ }))
+
it('should ingest text filter rules for more like', fakeAsync(() => {
const moreLikeSpy = jest.spyOn(documentService, 'get')
moreLikeSpy.mockReturnValue(of({ id: 1, title: 'Foo Bar' }))
@@ -1372,6 +1394,34 @@ describe('FilterEditorComponent', () => {
])
}))
+ it('should leave relative dates not in quick list intact', fakeAsync(() => {
+ component.textFilterInput.nativeElement.value = 'created:[-2 week to now]'
+ component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+ const textFieldTargetDropdown = fixture.debugElement.queryAll(
+ By.directive(NgbDropdownItem)
+ )[4]
+ textFieldTargetDropdown.triggerEventHandler('click')
+ fixture.detectChanges()
+ tick(400)
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_FULLTEXT_QUERY,
+ value: 'created:[-2 week to now]',
+ },
+ ])
+
+ component.textFilterInput.nativeElement.value = 'added:[-2 month to now]'
+ component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
+ fixture.detectChanges()
+ tick(400)
+ expect(component.filterRules).toEqual([
+ {
+ rule_type: FILTER_FULLTEXT_QUERY,
+ value: 'added:[-2 month to now]',
+ },
+ ])
+ }))
+
it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
const dateAddedDropdown = 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 b11874d7c..a6aafe049 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
@@ -362,10 +362,11 @@ export class FilterEditorComponent
this.dateCreatedRelativeDate =
RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.dateQuery == match[1]
- )?.relativeDate
+ )?.relativeDate ?? null
}
}
)
+ if (this.dateCreatedRelativeDate === null) textQueryArgs.push(arg) // relative query not in the quick list
} else if (arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)) {
;[...arg.matchAll(RELATIVE_DATE_QUERY_REGEXP_ADDED)].forEach(
(match) => {
@@ -373,10 +374,11 @@ export class FilterEditorComponent
this.dateAddedRelativeDate =
RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.dateQuery == match[1]
- )?.relativeDate
+ )?.relativeDate ?? null
}
}
)
+ if (this.dateAddedRelativeDate === null) textQueryArgs.push(arg) // relative query not in the quick list
} else {
textQueryArgs.push(arg)
}
@@ -787,27 +789,6 @@ export class FilterEditorComponent
})
}
}
- if (
- this.dateCreatedRelativeDate == null &&
- this.dateAddedRelativeDate == null
- ) {
- const existingRule = filterRules.find(
- (fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
- )
- if (
- existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_CREATED) ||
- existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)
- ) {
- // remove any existing date query
- existingRule.value = existingRule.value
- .replace(RELATIVE_DATE_QUERY_REGEXP_CREATED, '')
- .replace(RELATIVE_DATE_QUERY_REGEXP_ADDED, '')
- if (existingRule.value.replace(',', '').trim() === '') {
- // if its empty now, remove it entirely
- filterRules.splice(filterRules.indexOf(existingRule), 1)
- }
- }
- }
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
filterRules.push({
rule_type: FILTER_OWNER,
diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.html b/src-ui/src/app/components/manage/management-list/management-list.component.html
index d627a1540..997ca00bd 100644
--- a/src-ui/src/app/components/manage/management-list/management-list.component.html
+++ b/src-ui/src/app/components/manage/management-list/management-list.component.html
@@ -2,10 +2,10 @@
-
-
- @if (!isLoading) {
-
- @if (collectionSize > 0) {
-
- {collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}
- @if (selectedObjects.size > 0) {
- ({{selectedObjects.size}} selected)
- }
+
+
+ Edit
+
+
+ Delete
+
+
+
+
+ }
+
+
- }
- @if (collectionSize > 20) {
-
- }
-
- }
+
+ @if (!isLoading) {
+
+ @if (collectionSize > 0) {
+
+ {collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}
+ @if (selectedObjects.size > 0) {
+ ({{selectedObjects.size}} selected)
+ }
+
+ }
+ @if (collectionSize > 20) {
+
+ }
+
+ }
diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts
index 280c40ca8..c349fa935 100644
--- a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts
+++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts
@@ -13,7 +13,6 @@ import {
NgbModalModule,
NgbModalRef,
NgbPaginationModule,
- NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import { Tag } from 'src/app/data/tag'
@@ -24,7 +23,10 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { TagListComponent } from '../tag-list/tag-list.component'
import { ManagementListComponent } from './management-list.component'
-import { PermissionsService } from 'src/app/services/permissions.service'
+import {
+ PermissionAction,
+ PermissionsService,
+} from 'src/app/services/permissions.service'
import { ToastService } from 'src/app/services/toast.service'
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
@@ -38,7 +40,6 @@ import { MATCH_NONE } from 'src/app/data/matching-model'
import { MATCH_LITERAL } from 'src/app/data/matching-model'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
-import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
const tags: Tag[] = [
@@ -67,6 +68,7 @@ describe('ManagementListComponent', () => {
let modalService: NgbModal
let toastService: ToastService
let documentListViewService: DocumentListViewService
+ let permissionsService: PermissionsService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -78,20 +80,8 @@ describe('ManagementListComponent', () => {
SafeHtmlPipe,
ConfirmDialogComponent,
PermissionsDialogComponent,
- ConfirmButtonComponent,
- ],
- providers: [
- {
- provide: PermissionsService,
- useValue: {
- currentUserCan: () => true,
- currentUserHasObjectPermissions: () => true,
- currentUserOwnsObject: () => true,
- },
- },
- DatePipe,
- PermissionsGuard,
],
+ providers: [DatePipe, PermissionsGuard],
imports: [
HttpClientTestingModule,
NgbPaginationModule,
@@ -100,7 +90,6 @@ describe('ManagementListComponent', () => {
NgbModalModule,
RouterTestingModule.withRoutes(routes),
NgxBootstrapIconsModule.pick(allIcons),
- NgbPopoverModule,
],
}).compileComponents()
@@ -119,6 +108,14 @@ describe('ManagementListComponent', () => {
})
}
)
+ permissionsService = TestBed.inject(PermissionsService)
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+ jest
+ .spyOn(permissionsService, 'currentUserHasObjectPermissions')
+ .mockReturnValue(true)
+ jest
+ .spyOn(permissionsService, 'currentUserOwnsObject')
+ .mockReturnValue(true)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
@@ -197,23 +194,27 @@ describe('ManagementListComponent', () => {
})
it('should support delete, show notification on error / success', () => {
+ let modal: NgbModalRef
+ modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const deleteSpy = jest.spyOn(tagService, 'delete')
const reloadSpy = jest.spyOn(component, 'reloadData')
- const deleteButton = fixture.debugElement.query(
- By.directive(ConfirmButtonComponent)
- )
+ const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8]
+ deleteButton.triggerEventHandler('click')
+
+ expect(modal).not.toBeUndefined()
+ const editDialog = modal.componentInstance as ConfirmDialogComponent
// fail first
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
- deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
+ editDialog.confirmClicked.emit()
expect(toastErrorSpy).toHaveBeenCalled()
expect(reloadSpy).not.toHaveBeenCalled()
// succeed
deleteSpy.mockReturnValueOnce(of(true))
- deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
+ editDialog.confirmClicked.emit()
expect(reloadSpy).toHaveBeenCalled()
})
@@ -312,4 +313,10 @@ describe('ManagementListComponent', () => {
expect(bulkEditSpy).toHaveBeenCalled()
expect(successToastSpy).toHaveBeenCalled()
})
+
+ it('should disallow bulk permissions or delete objects if no global perms', () => {
+ jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
+ expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
+ expect(component.userCanBulkEdit(PermissionAction.Change)).toBeFalsy()
+ })
})
diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts
index 8f0947f1c..883731488 100644
--- a/src-ui/src/app/components/manage/management-list/management-list.component.ts
+++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts
@@ -22,6 +22,7 @@ import {
} from 'src/app/directives/sortable.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
+ PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
@@ -194,21 +195,34 @@ export abstract class ManagementListComponent
])
}
- deleteObject(object: T) {
- this.service
- .delete(object)
- .pipe(takeUntil(this.unsubscribeNotifier))
- .subscribe({
- next: () => {
- this.reloadData()
- },
- error: (error) => {
- this.toastService.showError(
- $localize`Error while deleting element`,
- error
- )
- },
- })
+ openDeleteDialog(object: T) {
+ var activeModal = this.modalService.open(ConfirmDialogComponent, {
+ backdrop: 'static',
+ })
+ activeModal.componentInstance.title = $localize`Confirm delete`
+ activeModal.componentInstance.messageBold = this.getDeleteMessage(object)
+ activeModal.componentInstance.message = $localize`Associated documents will not be deleted.`
+ activeModal.componentInstance.btnClass = 'btn-danger'
+ activeModal.componentInstance.btnCaption = $localize`Delete`
+ activeModal.componentInstance.confirmClicked.subscribe(() => {
+ activeModal.componentInstance.buttonsEnabled = false
+ this.service
+ .delete(object)
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe({
+ next: () => {
+ activeModal.close()
+ this.reloadData()
+ },
+ error: (error) => {
+ activeModal.componentInstance.buttonsEnabled = true
+ this.toastService.showError(
+ $localize`Error while deleting element`,
+ error
+ )
+ },
+ })
+ })
}
get nameFilter() {
@@ -234,7 +248,9 @@ export abstract class ManagementListComponent
)
}
- get userOwnsAll(): boolean {
+ userCanBulkEdit(action: PermissionAction): boolean {
+ if (!this.permissionsService.currentUserCan(action, this.permissionType))
+ return false
let ownsAll: boolean = true
const objects = this.data.filter((o) => this.selectedObjects.has(o.id))
ownsAll = objects.every((o) =>
diff --git a/src-ui/src/app/guards/dirty-form.guard.spec.ts b/src-ui/src/app/guards/dirty-form.guard.spec.ts
index 24ee24f74..c5c473b27 100644
--- a/src-ui/src/app/guards/dirty-form.guard.spec.ts
+++ b/src-ui/src/app/guards/dirty-form.guard.spec.ts
@@ -17,6 +17,7 @@ describe('DirtyFormGuard', () => {
let guard: DirtyFormGuard
let component: DirtyComponent
let route: ActivatedRoute
+ let modalService: NgbModal
beforeEach(() => {
TestBed.configureTestingModule({
@@ -37,6 +38,7 @@ describe('DirtyFormGuard', () => {
guard = TestBed.inject(DirtyFormGuard)
route = TestBed.inject(ActivatedRoute)
+ modalService = TestBed.inject(NgbModal)
const fixture = TestBed.createComponent(GenericDirtyComponent)
component = fixture.componentInstance
@@ -57,9 +59,14 @@ describe('DirtyFormGuard', () => {
component.isDirty$ = true
const confirmSpy = jest.spyOn(guard, 'confirmChanges')
const canDeactivate = guard.canDeactivate(component, route.snapshot)
+ let modal
+ modalService.activeInstances.subscribe((instances) => {
+ modal = instances[0]
+ })
canDeactivate.subscribe()
expect(canDeactivate).toHaveProperty('source') // Observable
expect(confirmSpy).toHaveBeenCalled()
+ modal.componentInstance.confirmClicked.next()
})
})
diff --git a/src-ui/src/app/services/open-documents.service.spec.ts b/src-ui/src/app/services/open-documents.service.spec.ts
index 3c8e29edd..69d2a4a37 100644
--- a/src-ui/src/app/services/open-documents.service.spec.ts
+++ b/src-ui/src/app/services/open-documents.service.spec.ts
@@ -108,6 +108,7 @@ describe('OpenDocumentsService', () => {
})
it('should close documents', () => {
+ openDocumentsService.closeDocument({ id: 999 } as any)
subscriptions.push(
openDocumentsService.openDocument(documents[0]).subscribe()
)
@@ -128,15 +129,21 @@ describe('OpenDocumentsService', () => {
subscriptions.push(
openDocumentsService.openDocument(documents[0]).subscribe()
)
+ openDocumentsService.setDirty({ id: 999 }, true) // coverage
openDocumentsService.setDirty(documents[0], false)
expect(openDocumentsService.hasDirty()).toBeFalsy()
openDocumentsService.setDirty(documents[0], true)
expect(openDocumentsService.hasDirty()).toBeTruthy()
+ let openModal
+ modalService.activeInstances.subscribe((instances) => {
+ openModal = instances[0]
+ })
const modalSpy = jest.spyOn(modalService, 'open')
subscriptions.push(
openDocumentsService.closeDocument(documents[0]).subscribe()
)
expect(modalSpy).toHaveBeenCalled()
+ openModal.componentInstance.confirmClicked.next()
})
it('should allow set dirty status, warn on closeAll', () => {
@@ -148,9 +155,14 @@ describe('OpenDocumentsService', () => {
)
openDocumentsService.setDirty(documents[0], true)
expect(openDocumentsService.hasDirty()).toBeTruthy()
+ let openModal
+ modalService.activeInstances.subscribe((instances) => {
+ openModal = instances[0]
+ })
const modalSpy = jest.spyOn(modalService, 'open')
subscriptions.push(openDocumentsService.closeAll().subscribe())
expect(modalSpy).toHaveBeenCalled()
+ openModal.componentInstance.confirmClicked.next()
})
it('should load open documents from localStorage', () => {
diff --git a/src-ui/src/app/services/rest/mail-account.service.spec.ts b/src-ui/src/app/services/rest/mail-account.service.spec.ts
index 80a66f28b..64974d834 100644
--- a/src-ui/src/app/services/rest/mail-account.service.spec.ts
+++ b/src-ui/src/app/services/rest/mail-account.service.spec.ts
@@ -58,12 +58,25 @@ describe(`Additional service tests for MailAccountService`, () => {
it('should support patchMany', () => {
subscription = service.patchMany(mail_accounts).subscribe()
mail_accounts.forEach((mail_account) => {
- const reqs = httpTestingController.match(
+ const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${mail_account.id}/`
)
- expect(reqs).toHaveLength(1)
- expect(reqs[0].request.method).toEqual('PATCH')
+ expect(req.request.method).toEqual('PATCH')
+ req.flush(mail_account)
})
+ httpTestingController.expectOne(
+ `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+ )
+ })
+
+ it('should support reload', () => {
+ service['reload']()
+ const req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+ )
+ expect(req.request.method).toEqual('GET')
+ req.flush({ results: mail_accounts })
+ expect(service.allAccounts).toEqual(mail_accounts)
})
beforeEach(() => {
diff --git a/src-ui/src/app/services/rest/mail-rule.service.spec.ts b/src-ui/src/app/services/rest/mail-rule.service.spec.ts
index cc5ac9928..ea84e8b86 100644
--- a/src-ui/src/app/services/rest/mail-rule.service.spec.ts
+++ b/src-ui/src/app/services/rest/mail-rule.service.spec.ts
@@ -76,12 +76,26 @@ describe(`Additional service tests for MailRuleService`, () => {
it('should support patchMany', () => {
subscription = service.patchMany(mail_rules).subscribe()
mail_rules.forEach((mail_rule) => {
- const reqs = httpTestingController.match(
+ const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${mail_rule.id}/`
)
- expect(reqs).toHaveLength(1)
- expect(reqs[0].request.method).toEqual('PATCH')
+ expect(req.request.method).toEqual('PATCH')
+ req.flush(mail_rule)
})
+ const reloadReq = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+ )
+ reloadReq.flush({ results: mail_rules })
+ })
+
+ it('should support reload', () => {
+ service['reload']()
+ const req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+ )
+ expect(req.request.method).toEqual('GET')
+ req.flush({ results: mail_rules })
+ expect(service.allRules).toEqual(mail_rules)
})
beforeEach(() => {
diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss
index 1b982b2b0..bc1efaf23 100644
--- a/src-ui/src/styles.scss
+++ b/src-ui/src/styles.scss
@@ -262,7 +262,7 @@ a.btn-link:focus-visible,
}
.ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
- background-color: var(--pngx-bg-darker) !important;
+ background-color: var(--pngx-bg-alt) !important;
color: var(--pngx-body-color-accent) !important;
}
@@ -439,7 +439,7 @@ ul.pagination {
color: var(--bs-body-color);
&:hover, &:focus {
- background-color: var(--pngx-bg-darker);
+ background-color: var(--pngx-bg-alt);
color: var(--bs-body-color);
}
diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py
index 919530544..97f9fcc59 100644
--- a/src/documents/management/commands/document_consumer.py
+++ b/src/documents/management/commands/document_consumer.py
@@ -286,10 +286,10 @@ class Command(BaseCommand):
def handle_inotify(self, directory, recursive, is_testing: bool):
logger.info(f"Using inotify to watch directory for changes: {directory}")
- timeout = None
+ timeout_ms = None
if is_testing:
- timeout = self.testing_timeout_ms
- logger.debug(f"Configuring timeout to {timeout}ms")
+ timeout_ms = self.testing_timeout_ms
+ logger.debug(f"Configuring timeout to {timeout_ms}ms")
inotify = INotify()
inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO | flags.MODIFY
@@ -298,7 +298,8 @@ class Command(BaseCommand):
else:
descriptor = inotify.add_watch(directory, inotify_flags)
- inotify_debounce: Final[float] = settings.CONSUMER_INOTIFY_DELAY
+ inotify_debounce_secs: Final[float] = settings.CONSUMER_INOTIFY_DELAY
+ inotify_debounce_ms: Final[int] = inotify_debounce_secs * 1000
finished = False
@@ -306,7 +307,7 @@ class Command(BaseCommand):
while not finished:
try:
- for event in inotify.read(timeout=timeout):
+ for event in inotify.read(timeout=timeout_ms):
path = inotify.get_path(event.wd) if recursive else directory
filepath = os.path.join(path, event.name)
if flags.MODIFY in flags.from_mask(event.mask):
@@ -323,7 +324,7 @@ class Command(BaseCommand):
# Current time - last time over the configured timeout
waited_long_enough = (
monotonic() - last_event_time
- ) > inotify_debounce
+ ) > inotify_debounce_secs
# Also make sure the file exists still, some scanners might write a
# temporary file first
@@ -342,11 +343,11 @@ class Command(BaseCommand):
# If files are waiting, need to exit read() to check them
# Otherwise, go back to infinite sleep time, but only if not testing
if len(notified_files) > 0:
- timeout = inotify_debounce
+ timeout_ms = inotify_debounce_ms
elif is_testing:
- timeout = self.testing_timeout_ms
+ timeout_ms = self.testing_timeout_ms
else:
- timeout = None
+ timeout_ms = None
if self.stop_flag.is_set():
logger.debug("Finishing because event is set")
diff --git a/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py
index 521de61b8..2cdd631bb 100644
--- a/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py
+++ b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py
@@ -4,26 +4,17 @@ import django.db.models.deletion
import multiselectfield.db.fields
from django.conf import settings
from django.contrib.auth.management import create_permissions
-from django.contrib.auth.models import Group
-from django.contrib.auth.models import Permission
-from django.contrib.auth.models import User
from django.db import migrations
from django.db import models
from django.db import transaction
from django.db.models import Q
-from documents.models import Correspondent
-from documents.models import CustomField
-from documents.models import DocumentType
-from documents.models import StoragePath
-from documents.models import Tag
-from documents.models import Workflow
-from documents.models import WorkflowAction
-from documents.models import WorkflowTrigger
-from paperless_mail.models import MailRule
-
def add_workflow_permissions(apps, schema_editor):
+ app_name = "auth"
+ User = apps.get_model(app_label=app_name, model_name="User")
+ Group = apps.get_model(app_label=app_name, model_name="Group")
+ Permission = apps.get_model(app_label=app_name, model_name="Permission")
# create permissions without waiting for post_migrate signal
for app_config in apps.get_app_configs():
app_config.models_module = True
@@ -43,6 +34,10 @@ def add_workflow_permissions(apps, schema_editor):
def remove_workflow_permissions(apps, schema_editor):
+ app_name = "auth"
+ User = apps.get_model(app_label=app_name, model_name="User")
+ Group = apps.get_model(app_label=app_name, model_name="Group")
+ Permission = apps.get_model(app_label=app_name, model_name="Permission")
workflow_permissions = Permission.objects.filter(
codename__contains="workflow",
)
@@ -59,15 +54,28 @@ def migrate_consumption_templates(apps, schema_editor):
Migrate consumption templates to workflows. At this point ConsumptionTemplate still exists
but objects are not returned as their true model so we have to manually do that
"""
- model_name = "ConsumptionTemplate"
app_name = "documents"
- ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name)
+ ConsumptionTemplate = apps.get_model(
+ app_label=app_name,
+ model_name="ConsumptionTemplate",
+ )
+ Workflow = apps.get_model(app_label=app_name, model_name="Workflow")
+ WorkflowAction = apps.get_model(app_label=app_name, model_name="WorkflowAction")
+ WorkflowTrigger = apps.get_model(app_label=app_name, model_name="WorkflowTrigger")
+ DocumentType = apps.get_model(app_label=app_name, model_name="DocumentType")
+ Correspondent = apps.get_model(app_label=app_name, model_name="Correspondent")
+ StoragePath = apps.get_model(app_label=app_name, model_name="StoragePath")
+ Tag = apps.get_model(app_label=app_name, model_name="Tag")
+ CustomField = apps.get_model(app_label=app_name, model_name="CustomField")
+ MailRule = apps.get_model(app_label="paperless_mail", model_name="MailRule")
+ User = apps.get_model(app_label="auth", model_name="User")
+ Group = apps.get_model(app_label="auth", model_name="Group")
with transaction.atomic():
for template in ConsumptionTemplate.objects.all():
trigger = WorkflowTrigger(
- type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+ type=1, # WorkflowTriggerType.CONSUMPTION
sources=template.sources,
filter_path=template.filter_path,
filter_filename=template.filter_filename,
@@ -143,10 +151,13 @@ def migrate_consumption_templates(apps, schema_editor):
def unmigrate_consumption_templates(apps, schema_editor):
- model_name = "ConsumptionTemplate"
app_name = "documents"
- ConsumptionTemplate = apps.get_model(app_label=app_name, model_name=model_name)
+ ConsumptionTemplate = apps.get_model(
+ app_label=app_name,
+ model_name="ConsumptionTemplate",
+ )
+ Workflow = apps.get_model(app_label=app_name, model_name="Workflow")
for workflow in Workflow.objects.all():
template = ConsumptionTemplate.objects.create(
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 9f2b9a222..b8067df86 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -575,7 +575,11 @@ def run_workflow(
else ""
),
timezone.localtime(document.added),
- document.original_filename,
+ (
+ document.original_filename
+ if document.original_filename is not None
+ else ""
+ ),
timezone.localtime(document.created),
)
except Exception:
diff --git a/src/documents/tests/test_api_objects.py b/src/documents/tests/test_api_objects.py
index 3b38f2b5f..9a0ccd598 100644
--- a/src/documents/tests/test_api_objects.py
+++ b/src/documents/tests/test_api_objects.py
@@ -1,6 +1,7 @@
import json
from unittest import mock
+from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.test import APITestCase
@@ -310,17 +311,77 @@ class TestBulkEditObjects(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(StoragePath.objects.count(), 0)
- def test_bulk_edit_object_permissions_insufficient_perms(self):
+ def test_bulk_edit_object_permissions_insufficient_global_perms(self):
"""
GIVEN:
- - Objects owned by user other than logged in user
+ - Existing objects, user does not have global delete permissions
WHEN:
- bulk_edit_objects API endpoint is called with delete operation
THEN:
- User is not able to delete objects
"""
- self.t1.owner = User.objects.get(username="temp_admin")
- self.t1.save()
+ self.client.force_authenticate(user=self.user1)
+
+ response = self.client.post(
+ "/api/bulk_edit_objects/",
+ json.dumps(
+ {
+ "objects": [self.t1.id, self.t2.id],
+ "object_type": "tags",
+ "operation": "delete",
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(response.content, b"Insufficient permissions")
+
+ def test_bulk_edit_object_permissions_sufficient_global_perms(self):
+ """
+ GIVEN:
+ - Existing objects, user does have global delete permissions
+ WHEN:
+ - bulk_edit_objects API endpoint is called with delete operation
+ THEN:
+ - User is able to delete objects
+ """
+ self.user1.user_permissions.add(
+ *Permission.objects.filter(codename="delete_tag"),
+ )
+ self.user1.save()
+ self.client.force_authenticate(user=self.user1)
+
+ response = self.client.post(
+ "/api/bulk_edit_objects/",
+ json.dumps(
+ {
+ "objects": [self.t1.id, self.t2.id],
+ "object_type": "tags",
+ "operation": "delete",
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_bulk_edit_object_permissions_insufficient_object_perms(self):
+ """
+ GIVEN:
+ - Objects owned by user other than logged in user
+ WHEN:
+ - bulk_edit_objects API endpoint is called with delete operation
+ THEN:
+ - User is not able to delete objects
+ """
+ self.t2.owner = User.objects.get(username="temp_admin")
+ self.t2.save()
+
+ self.user1.user_permissions.add(
+ *Permission.objects.filter(codename="delete_tag"),
+ )
+ self.user1.save()
self.client.force_authenticate(user=self.user1)
response = self.client.post(
diff --git a/src/documents/views.py b/src/documents/views.py
index c73a8050b..6169ac5bb 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -1419,7 +1419,15 @@ class BulkEditObjectsView(GenericAPIView, PassUserMixin):
objs = object_class.objects.filter(pk__in=object_ids)
if not user.is_superuser:
- has_perms = all((obj.owner == user or obj.owner is None) for obj in objs)
+ model_name = object_class._meta.verbose_name
+ perm = (
+ f"documents.change_{model_name}"
+ if operation == "set_permissions"
+ else f"documents.delete_{model_name}"
+ )
+ has_perms = user.has_perm(perm) and all(
+ (obj.owner == user or obj.owner is None) for obj in objs
+ )
if not has_perms:
return HttpResponseForbidden("Insufficient permissions")
diff --git a/src/paperless/views.py b/src/paperless/views.py
index 1151ceed5..974830d83 100644
--- a/src/paperless/views.py
+++ b/src/paperless/views.py
@@ -14,7 +14,7 @@ from rest_framework.authtoken.models import Token
from rest_framework.filters import OrderingFilter
from rest_framework.generics import GenericAPIView
from rest_framework.pagination import PageNumberPagination
-from rest_framework.permissions import DjangoObjectPermissions
+from rest_framework.permissions import DjangoModelPermissions
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -171,7 +171,7 @@ class ApplicationConfigurationViewSet(ModelViewSet):
queryset = ApplicationConfiguration.objects
serializer_class = ApplicationConfigurationSerializer
- permission_classes = (IsAuthenticated, DjangoObjectPermissions)
+ permission_classes = (IsAuthenticated, DjangoModelPermissions)
class DisconnectSocialAccountView(GenericAPIView):
diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py
index a4ff6478c..a41b52a6e 100644
--- a/src/paperless_mail/mail.py
+++ b/src/paperless_mail/mail.py
@@ -831,6 +831,7 @@ class MailAccountHandler(LoggingMixin):
input_doc = ConsumableDocument(
source=DocumentSource.MailFetch,
original_file=temp_filename,
+ mailrule_id=rule.pk,
)
doc_overrides = DocumentMetadataOverrides(
title=message.subject,