@@ -147,7 +139,6 @@
-
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 cb0d09fe0..d83f02678 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
@@ -1,22 +1,14 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
-import { Observable } from 'rxjs';
-import { tap } from 'rxjs/operators';
import { PaperlessDocument } from 'src/app/data/paperless-document';
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view';
import { DocumentListViewService } from 'src/app/services/document-list-view.service';
-import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
-import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
-import { DocumentService, DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service';
-import { TagService } from 'src/app/services/rest/tag.service';
+import { DOCUMENT_SORT_FIELDS } from 'src/app/services/rest/document.service';
import { SavedViewService } from 'src/app/services/rest/saved-view.service';
import { Toast, ToastService } from 'src/app/services/toast.service';
-import { FilterEditorComponent } from '../filter-editor/filter-editor.component';
-import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component';
-import { SelectDialogComponent } from '../common/select-dialog/select-dialog.component';
+import { FilterEditorComponent } from './filter-editor/filter-editor.component';
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component';
-import { OpenDocumentsService } from 'src/app/services/open-documents.service';
@Component({
selector: 'app-document-list',
@@ -31,12 +23,7 @@ export class DocumentListComponent implements OnInit {
public route: ActivatedRoute,
private router: Router,
private toastService: ToastService,
- public modalService: NgbModal,
- private correspondentService: CorrespondentService,
- private documentTypeService: DocumentTypeService,
- private tagService: TagService,
- private documentService: DocumentService,
- private openDocumentService: OpenDocumentsService) { }
+ private modalService: NgbModal) { }
@ViewChild("filterEditor")
private filterEditor: FilterEditorComponent
@@ -55,6 +42,10 @@ export class DocumentListComponent implements OnInit {
return DOCUMENT_SORT_FIELDS
}
+ get isBulkEditing(): boolean {
+ return this.list.selected.size > 0
+ }
+
saveDisplayMode() {
localStorage.setItem('document-list:displayMode', this.displayMode)
}
@@ -115,133 +106,27 @@ export class DocumentListComponent implements OnInit {
}
clickTag(tagID: number) {
- this.filterEditor.toggleTag(tagID)
+ this.list.selectNone()
+ setTimeout(() => {
+ this.filterEditor.toggleTag(tagID)
+ })
}
clickCorrespondent(correspondentID: number) {
- this.filterEditor.toggleCorrespondent(correspondentID)
+ this.list.selectNone()
+ setTimeout(() => {
+ this.filterEditor.toggleCorrespondent(correspondentID)
+ })
}
clickDocumentType(documentTypeID: number) {
- this.filterEditor.toggleDocumentType(documentTypeID)
+ this.list.selectNone()
+ setTimeout(() => {
+ this.filterEditor.toggleDocumentType(documentTypeID)
+ })
}
trackByDocumentId(index, item: PaperlessDocument) {
return item.id
}
-
- private executeBulkOperation(method: string, args): Observable
{
- return this.documentService.bulkEdit(Array.from(this.list.selected), method, args).pipe(
- tap(() => {
- this.list.reload()
- this.list.selected.forEach(id => {
- this.openDocumentService.refreshDocument(id)
- })
- this.list.selectNone()
- })
- )
- }
-
- bulkSetCorrespondent() {
- let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'})
- modal.componentInstance.title = "Select correspondent"
- modal.componentInstance.message = `Select the correspondent you wish to assign to ${this.list.selected.size} selected document(s):`
- this.correspondentService.listAll().subscribe(response => {
- modal.componentInstance.objects = response.results
- })
- modal.componentInstance.selectClicked.subscribe(selectedId => {
- this.executeBulkOperation('set_correspondent', {"correspondent": selectedId}).subscribe(
- response => {
- modal.close()
- }
- )
- })
- }
-
- bulkRemoveCorrespondent() {
- let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
- modal.componentInstance.title = "Remove correspondent"
- modal.componentInstance.message = `This operation will remove the correspondent from all ${this.list.selected.size} selected document(s).`
- modal.componentInstance.confirmClicked.subscribe(() => {
- this.executeBulkOperation('set_correspondent', {"correspondent": null}).subscribe(r => {
- modal.close()
- })
- })
- }
-
- bulkSetDocumentType() {
- let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'})
- modal.componentInstance.title = "Select document type"
- modal.componentInstance.message = `Select the document type you wish to assign to ${this.list.selected.size} selected document(s):`
- this.documentTypeService.listAll().subscribe(response => {
- modal.componentInstance.objects = response.results
- })
- modal.componentInstance.selectClicked.subscribe(selectedId => {
- this.executeBulkOperation('set_document_type', {"document_type": selectedId}).subscribe(
- response => {
- modal.close()
- }
- )
- })
- }
-
- bulkRemoveDocumentType() {
- let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
- modal.componentInstance.title = "Remove document type"
- modal.componentInstance.message = `This operation will remove the document type from all ${this.list.selected.size} selected document(s).`
- modal.componentInstance.confirmClicked.subscribe(() => {
- this.executeBulkOperation('set_document_type', {"document_type": null}).subscribe(r => {
- modal.close()
- })
- })
- }
-
- bulkAddTag() {
- let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'})
- modal.componentInstance.title = "Select tag"
- modal.componentInstance.message = `Select the tag you wish to assign to ${this.list.selected.size} selected document(s):`
- this.tagService.listAll().subscribe(response => {
- modal.componentInstance.objects = response.results
- })
- modal.componentInstance.selectClicked.subscribe(selectedId => {
- this.executeBulkOperation('add_tag', {"tag": selectedId}).subscribe(
- response => {
- modal.close()
- }
- )
- })
- }
-
- bulkRemoveTag() {
- let modal = this.modalService.open(SelectDialogComponent, {backdrop: 'static'})
- modal.componentInstance.title = "Select tag"
- modal.componentInstance.message = `Select the tag you wish to remove from ${this.list.selected.size} selected document(s):`
- this.tagService.listAll().subscribe(response => {
- modal.componentInstance.objects = response.results
- })
- modal.componentInstance.selectClicked.subscribe(selectedId => {
- this.executeBulkOperation('remove_tag', {"tag": selectedId}).subscribe(
- response => {
- modal.close()
- }
- )
- })
- }
-
- bulkDelete() {
- let modal = this.modalService.open(ConfirmDialogComponent, {backdrop: 'static'})
- modal.componentInstance.delayConfirm(5)
- modal.componentInstance.title = "Delete confirm"
- modal.componentInstance.messageBold = `This operation will permanently delete all ${this.list.selected.size} selected document(s).`
- modal.componentInstance.message = `This operation cannot be undone.`
- modal.componentInstance.btnClass = "btn-danger"
- modal.componentInstance.btnCaption = "Delete document(s)"
- modal.componentInstance.confirmClicked.subscribe(() => {
- this.executeBulkOperation("delete", {}).subscribe(
- response => {
- modal.close()
- }
- )
- })
- }
}
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
new file mode 100644
index 000000000..f0c83ae73
--- /dev/null
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+ Clear all filters
+
+
+
+
diff --git a/src-ui/src/app/components/filter-editor/filter-editor.component.scss b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.scss
similarity index 100%
rename from src-ui/src/app/components/filter-editor/filter-editor.component.scss
rename to src-ui/src/app/components/document-list/filter-editor/filter-editor.component.scss
diff --git a/src-ui/src/app/components/filter-editor/filter-editor.component.spec.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
similarity index 100%
rename from src-ui/src/app/components/filter-editor/filter-editor.component.spec.ts
rename to src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
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
new file mode 100644
index 000000000..d4cac1a9d
--- /dev/null
+++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
@@ -0,0 +1,192 @@
+import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core';
+import { PaperlessTag } from 'src/app/data/paperless-tag';
+import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
+import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
+import { Subject, Subscription } from 'rxjs';
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
+import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
+import { TagService } from 'src/app/services/rest/tag.service';
+import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
+import { FilterRule } from 'src/app/data/filter-rule';
+import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE } from 'src/app/data/filter-rule-type';
+import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component';
+import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component';
+
+@Component({
+ selector: 'app-filter-editor',
+ templateUrl: './filter-editor.component.html',
+ styleUrls: ['./filter-editor.component.scss']
+})
+export class FilterEditorComponent implements OnInit, OnDestroy {
+
+ generateFilterName() {
+ if (this.filterRules.length == 1) {
+ let rule = this.filterRules[0]
+ switch(this.filterRules[0].rule_type) {
+
+ case FILTER_CORRESPONDENT:
+ return $localize`Correspondent: ${this.correspondents.find(c => c.id == +rule.value)?.name}`
+
+ case FILTER_DOCUMENT_TYPE:
+ return $localize`Type: ${this.documentTypes.find(dt => dt.id == +rule.value)?.name}`
+
+ case FILTER_HAS_TAG:
+ return $localize`Tag: ${this.tags.find(t => t.id == +rule.value)?.name}`
+
+ }
+ }
+
+ return ""
+ }
+
+ constructor(
+ private documentTypeService: DocumentTypeService,
+ private tagService: TagService,
+ private correspondentService: CorrespondentService
+ ) { }
+
+ tags: PaperlessTag[] = []
+ correspondents: PaperlessCorrespondent[] = []
+ documentTypes: PaperlessDocumentType[] = []
+
+ _titleFilter = ""
+
+ tagSelectionModel = new FilterableDropdownSelectionModel()
+ correspondentSelectionModel = new FilterableDropdownSelectionModel()
+ documentTypeSelectionModel = new FilterableDropdownSelectionModel()
+
+ dateCreatedBefore: string
+ dateCreatedAfter: string
+ dateAddedBefore: string
+ dateAddedAfter: string
+
+ @Input()
+ set filterRules (value: FilterRule[]) {
+ value.forEach(rule => {
+ switch (rule.rule_type) {
+ case FILTER_TITLE:
+ this._titleFilter = rule.value
+ break
+ case FILTER_CREATED_AFTER:
+ this.dateCreatedAfter = rule.value
+ break
+ case FILTER_CREATED_BEFORE:
+ this.dateCreatedBefore = rule.value
+ break
+ case FILTER_ADDED_AFTER:
+ this.dateAddedAfter = rule.value
+ break
+ case FILTER_ADDED_BEFORE:
+ this.dateAddedBefore = rule.value
+ break
+ case FILTER_HAS_TAG:
+ this.tagSelectionModel.set(+rule.value, ToggleableItemState.Selected, false)
+ break
+ case FILTER_CORRESPONDENT:
+ this.correspondentSelectionModel.set(+rule.value, ToggleableItemState.Selected, false)
+ break
+ case FILTER_DOCUMENT_TYPE:
+ this.documentTypeSelectionModel.set(+rule.value, ToggleableItemState.Selected, false)
+ break
+ }
+ })
+ }
+
+ @Output()
+ filterRulesChange = new EventEmitter()
+
+ updateRules() {
+ let filterRules: FilterRule[] = []
+ if (this._titleFilter) {
+ filterRules.push({rule_type: FILTER_TITLE, value: this._titleFilter})
+ }
+ if (this.tagSelectionModel.isNoneSelected()) {
+ filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"})
+ } else {
+ this.tagSelectionModel.getSelectedItems().filter(tag => tag.id).forEach(tag => {
+ filterRules.push({rule_type: FILTER_HAS_TAG, value: tag.id?.toString()})
+ })
+ }
+ this.correspondentSelectionModel.getSelectedItems().forEach(correspondent => {
+ filterRules.push({rule_type: FILTER_CORRESPONDENT, value: correspondent.id?.toString()})
+ })
+ this.documentTypeSelectionModel.getSelectedItems().forEach(documentType => {
+ filterRules.push({rule_type: FILTER_DOCUMENT_TYPE, value: documentType.id?.toString()})
+ })
+ if (this.dateCreatedBefore) {
+ filterRules.push({rule_type: FILTER_CREATED_BEFORE, value: this.dateCreatedBefore})
+ }
+ if (this.dateCreatedAfter) {
+ filterRules.push({rule_type: FILTER_CREATED_AFTER, value: this.dateCreatedAfter})
+ }
+ if (this.dateAddedBefore) {
+ filterRules.push({rule_type: FILTER_ADDED_BEFORE, value: this.dateAddedBefore})
+ }
+ if (this.dateAddedAfter) {
+ filterRules.push({rule_type: FILTER_ADDED_AFTER, value: this.dateAddedAfter})
+ }
+ this.filterRulesChange.next(filterRules)
+ }
+
+ hasFilters() {
+ return this._titleFilter ||
+ this.dateAddedAfter || this.dateAddedBefore || this.dateCreatedAfter || this.dateCreatedBefore ||
+ this.tagSelectionModel.selectionSize() || this.correspondentSelectionModel.selectionSize() || this.documentTypeSelectionModel.selectionSize()
+ }
+
+ get titleFilter() {
+ return this._titleFilter
+ }
+
+ set titleFilter(value) {
+ this.titleFilterDebounce.next(value)
+ }
+
+ titleFilterDebounce: Subject
+ subscription: Subscription
+
+ ngOnInit() {
+ this.tagService.listAll().subscribe(result => this.tags = result.results)
+ this.correspondentService.listAll().subscribe(result => this.correspondents = result.results)
+ this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results)
+
+ this.titleFilterDebounce = new Subject()
+
+ this.subscription = this.titleFilterDebounce.pipe(
+ debounceTime(400),
+ distinctUntilChanged()
+ ).subscribe(title => {
+ this._titleFilter = title
+ this.updateRules()
+ })
+ }
+
+ ngOnDestroy() {
+ this.titleFilterDebounce.complete()
+ }
+
+ clearSelected() {
+ this._titleFilter = ""
+ this.tagSelectionModel.clear(false)
+ this.documentTypeSelectionModel.clear(false)
+ this.correspondentSelectionModel.clear(false)
+ this.dateAddedBefore = null
+ this.dateAddedAfter = null
+ this.dateCreatedBefore = null
+ this.dateCreatedAfter = null
+ this.updateRules()
+ }
+
+ toggleTag(tagId: number) {
+ this.tagSelectionModel.toggle(tagId)
+ }
+
+ toggleCorrespondent(correspondentId: number) {
+ this.correspondentSelectionModel.toggle(correspondentId)
+ }
+
+ toggleDocumentType(documentTypeId: number) {
+ this.documentTypeSelectionModel.toggle(documentTypeId)
+ }
+
+}
diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.html b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.html
deleted file mode 100644
index 8dff12a33..000000000
--- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
- {{item.document_count}}
-
diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.spec.ts b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.spec.ts
deleted file mode 100644
index 5cf1fefa2..000000000
--- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { FilterDropodownButtonComponent } from './filter-dropdown-button.component';
-
-describe('FilterDropodownButtonComponent', () => {
- let component: FilterDropodownButtonComponent;
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- declarations: [ FilterDropodownButtonComponent ]
- })
- .compileComponents();
- });
-
- beforeEach(() => {
- fixture = TestBed.createComponent(FilterDropodownButtonComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.ts b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.ts
deleted file mode 100644
index d3ddd3cbf..000000000
--- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown-button/filter-dropdown-button.component.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core';
-import { PaperlessTag } from 'src/app/data/paperless-tag';
-import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
-import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
-
-@Component({
- selector: 'app-filter-dropdown-button',
- templateUrl: './filter-dropdown-button.component.html',
- styleUrls: ['./filter-dropdown-button.component.scss']
-})
-export class FilterDropdownButtonComponent implements OnInit {
-
- @Input()
- item: PaperlessTag | PaperlessDocumentType | PaperlessCorrespondent
-
- @Input()
- selected: boolean
-
- @Output()
- toggle = new EventEmitter()
-
- isTag: boolean
-
- ngOnInit() {
- this.isTag = 'is_inbox_tag' in this.item // ~ this.item instanceof PaperlessTag
- }
-
- toggleItem(): void {
- this.selected = !this.selected
- this.toggle.emit(this.item)
- }
-}
diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.html b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.html
deleted file mode 100644
index d0cbfc3c9..000000000
--- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.html
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
0 ? 'btn-primary' : 'btn-outline-primary'">
- {{title}}
-
-
-
-
-
- 0">
-
- {{itemsSelected?.length}}
-
-
-
-
-
diff --git a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.ts b/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.ts
deleted file mode 100644
index b9d3fca6f..000000000
--- a/src-ui/src/app/components/filter-editor/filter-dropdown/filter-dropdown.component.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Component, EventEmitter, Input, Output, ElementRef, ViewChild } from '@angular/core';
-import { ObjectWithId } from 'src/app/data/object-with-id';
-import { FilterPipe } from 'src/app/pipes/filter.pipe';
-import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
-
-@Component({
- selector: 'app-filter-dropdown',
- templateUrl: './filter-dropdown.component.html',
- styleUrls: ['./filter-dropdown.component.scss']
-})
-export class FilterDropdownComponent {
-
- constructor(private filterPipe: FilterPipe) { }
-
- @Input()
- items: ObjectWithId[]
-
- @Input()
- itemsSelected: ObjectWithId[]
-
- @Input()
- title: string
-
- @Input()
- icon: string
-
- @Output()
- toggle = new EventEmitter()
-
- @ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
- @ViewChild('filterDropdown') filterDropdown: NgbDropdown
-
- filterText: string
-
- toggleItem(item: ObjectWithId): void {
- this.toggle.emit(item)
- }
-
- isItemSelected(item: ObjectWithId): boolean {
- return this.itemsSelected?.find(i => i.id == item.id) !== undefined
- }
-
- dropdownOpenChange(open: boolean): void {
- if (open) {
- setTimeout(() => {
- this.listFilterTextInput.nativeElement.focus();
- }, 0);
- } else {
- this.filterText = ''
- }
- }
-
- listFilterEnter(): void {
- let filtered = this.filterPipe.transform(this.items, this.filterText)
- if (filtered.length == 1) this.toggleItem(filtered.shift())
- this.filterDropdown.close()
- }
-}
diff --git a/src-ui/src/app/components/filter-editor/filter-editor.component.html b/src-ui/src/app/components/filter-editor/filter-editor.component.html
deleted file mode 100644
index 6847a2902..000000000
--- a/src-ui/src/app/components/filter-editor/filter-editor.component.html
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Clear all filters
-
-
-
diff --git a/src-ui/src/app/components/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/filter-editor/filter-editor.component.ts
deleted file mode 100644
index 913c738a5..000000000
--- a/src-ui/src/app/components/filter-editor/filter-editor.component.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from '@angular/core';
-import { PaperlessTag } from 'src/app/data/paperless-tag';
-import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent';
-import { PaperlessDocumentType } from 'src/app/data/paperless-document-type';
-import { Subject, Subscription } from 'rxjs';
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
-import { NgbDateParserFormatter, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
-import { DocumentTypeService } from 'src/app/services/rest/document-type.service';
-import { TagService } from 'src/app/services/rest/tag.service';
-import { CorrespondentService } from 'src/app/services/rest/correspondent.service';
-import { FilterRule } from 'src/app/data/filter-rule';
-import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_HAS_TAG, FILTER_RULE_TYPES, FILTER_TITLE } from 'src/app/data/filter-rule-type';
-import { DateSelection } from './filter-dropdown-date/filter-dropdown-date.component';
-
-@Component({
- selector: 'app-filter-editor',
- templateUrl: './filter-editor.component.html',
- styleUrls: ['./filter-editor.component.scss']
-})
-export class FilterEditorComponent implements OnInit, OnDestroy {
-
- generateFilterName() {
- if (this.filterRules.length == 1) {
- let rule = this.filterRules[0]
- switch(this.filterRules[0].rule_type) {
-
- case FILTER_CORRESPONDENT:
- return `Correspondent: ${this.correspondents.find(c => c.id == +rule.value)?.name}`
-
- case FILTER_DOCUMENT_TYPE:
- return `Type: ${this.documentTypes.find(dt => dt.id == +rule.value)?.name}`
-
- case FILTER_HAS_TAG:
- return `Tag: ${this.tags.find(t => t.id == +rule.value)?.name}`
-
- }
- }
-
- return ""
- }
-
- constructor(
- private documentTypeService: DocumentTypeService,
- private tagService: TagService,
- private correspondentService: CorrespondentService,
- private dateParser: NgbDateParserFormatter
- ) { }
-
- tags: PaperlessTag[] = []
- correspondents: PaperlessCorrespondent[]
- documentTypes: PaperlessDocumentType[] = []
-
- @Input()
- filterRules: FilterRule[]
-
- @Output()
- filterRulesChange = new EventEmitter()
-
- hasFilters() {
- return this.filterRules.length > 0
- }
-
- get selectedTags(): PaperlessTag[] {
- let tagRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_HAS_TAG)
- return this.tags?.filter(t => tagRules.find(tr => +tr.value == t.id))
- }
-
- get selectedCorrespondents(): PaperlessCorrespondent[] {
- let correspondentRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_CORRESPONDENT)
- return this.correspondents?.filter(c => correspondentRules.find(cr => +cr.value == c.id))
- }
-
- get selectedDocumentTypes(): PaperlessDocumentType[] {
- let documentTypeRules: FilterRule[] = this.filterRules.filter(fr => fr.rule_type == FILTER_DOCUMENT_TYPE)
- return this.documentTypes?.filter(dt => documentTypeRules.find(dtr => +dtr.value == dt.id))
- }
-
- get titleFilter() {
- let existingRule = this.filterRules.find(rule => rule.rule_type == FILTER_TITLE)
- return existingRule ? existingRule.value : ''
- }
-
- set titleFilter(value) {
- this.titleFilterDebounce.next(value)
- }
-
- titleFilterDebounce: Subject
- subscription: Subscription
-
- ngOnInit() {
- this.tagService.listAll().subscribe(result => this.tags = result.results)
- this.correspondentService.listAll().subscribe(result => this.correspondents = result.results)
- this.documentTypeService.listAll().subscribe(result => this.documentTypes = result.results)
-
- this.titleFilterDebounce = new Subject()
-
- this.subscription = this.titleFilterDebounce.pipe(
- debounceTime(400),
- distinctUntilChanged()
- ).subscribe(title => {
- this.setTitleRule(title)
- })
- }
-
- ngOnDestroy() {
- this.titleFilterDebounce.complete()
- // TODO: not sure if both is necessary
- this.subscription.unsubscribe()
- }
-
- applyFilters() {
- this.filterRulesChange.next(this.filterRules)
- }
-
- clearSelected() {
- this.filterRules = []
- this.applyFilters()
- }
-
- private toggleFilterRule(filterRuleTypeID: number, value: number) {
-
- let filterRuleType = FILTER_RULE_TYPES.find(t => t.id == filterRuleTypeID)
-
- let existingRule = this.filterRules.find(rule => rule.rule_type == filterRuleTypeID && rule.value == value?.toString())
- let existingRuleOfSameType = this.filterRules.find(rule => rule.rule_type == filterRuleTypeID)
-
- if (existingRule) {
- // if this exact rule already exists, remove it in all cases.
- this.filterRules.splice(this.filterRules.indexOf(existingRule), 1)
- } else if (filterRuleType.multi || !existingRuleOfSameType) {
- // if we allow multiple rules per type, or no rule of this type already exists, push a new rule.
- this.filterRules.push({rule_type: filterRuleTypeID, value: value?.toString()})
- } else {
- // otherwise (i.e., no multi support AND there's already a rule of this type), update the rule.
- existingRuleOfSameType.value = value?.toString()
- }
- this.applyFilters()
- }
-
- private setTitleRule(title: string) {
- let existingRule = this.filterRules.find(rule => rule.rule_type == FILTER_TITLE)
-
- if (!existingRule && title) {
- this.filterRules.push({rule_type: FILTER_TITLE, value: title})
- } else if (existingRule && !title) {
- this.filterRules.splice(this.filterRules.findIndex(rule => rule.rule_type == FILTER_TITLE), 1)
- } else if (existingRule && title) {
- existingRule.value = title
- }
- this.applyFilters()
- }
-
- toggleTag(tagId: number) {
- this.toggleFilterRule(FILTER_HAS_TAG, tagId)
- }
-
- toggleCorrespondent(correspondentId: number) {
- this.toggleFilterRule(FILTER_CORRESPONDENT, correspondentId)
- }
-
- toggleDocumentType(documentTypeId: number) {
- this.toggleFilterRule(FILTER_DOCUMENT_TYPE, documentTypeId)
- }
-
-
-
- // Date handling
-
-
- onDatesCreatedSet(dates: DateSelection) {
- this.setDateCreatedBefore(dates.before)
- this.setDateCreatedAfter(dates.after)
- this.applyFilters()
- }
-
- onDatesAddedSet(dates: DateSelection) {
- this.setDateAddedBefore(dates.before)
- this.setDateAddedAfter(dates.after)
- this.applyFilters()
- }
-
- get dateCreatedBefore(): string {
- let createdBeforeRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_CREATED_BEFORE)
- return createdBeforeRule ? createdBeforeRule.value : null
- }
-
- get dateCreatedAfter(): string {
- let createdAfterRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_CREATED_AFTER)
- return createdAfterRule ? createdAfterRule.value : null
- }
-
- get dateAddedBefore(): string {
- let addedBeforeRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_ADDED_BEFORE)
- return addedBeforeRule ? addedBeforeRule.value : null
- }
-
- get dateAddedAfter(): string {
- let addedAfterRule: FilterRule = this.filterRules.find(fr => fr.rule_type == FILTER_ADDED_AFTER)
- return addedAfterRule ? addedAfterRule.value : null
- }
-
- setDateCreatedBefore(date?: string) {
- if (date) this.setDateFilter(date, FILTER_CREATED_BEFORE)
- else this.clearDateFilter(FILTER_CREATED_BEFORE)
- }
-
- setDateCreatedAfter(date?: string) {
- if (date) this.setDateFilter(date, FILTER_CREATED_AFTER)
- else this.clearDateFilter(FILTER_CREATED_AFTER)
- }
-
- setDateAddedBefore(date?: string) {
- if (date) this.setDateFilter(date, FILTER_ADDED_BEFORE)
- else this.clearDateFilter(FILTER_ADDED_BEFORE)
- }
-
- setDateAddedAfter(date?: string) {
- if (date) this.setDateFilter(date, FILTER_ADDED_AFTER)
- else this.clearDateFilter(FILTER_ADDED_AFTER)
- }
-
- setDateFilter(date: string, dateRuleTypeID: number) {
- let existingRule = this.filterRules.find(rule => rule.rule_type == dateRuleTypeID)
-
- if (existingRule) {
- existingRule.value = date
- } else {
- this.filterRules.push({rule_type: dateRuleTypeID, value: date})
- }
- }
-
- clearDateFilter(dateRuleTypeID: number) {
- let ruleIndex = this.filterRules.findIndex(rule => rule.rule_type == dateRuleTypeID)
- if (ruleIndex != -1) {
- this.filterRules.splice(ruleIndex, 1)
- }
- }
-
-}
diff --git a/src-ui/src/app/data/matching-model.ts b/src-ui/src/app/data/matching-model.ts
index dd9ae95ff..dfb007ecb 100644
--- a/src-ui/src/app/data/matching-model.ts
+++ b/src-ui/src/app/data/matching-model.ts
@@ -29,4 +29,6 @@ export interface MatchingModel extends ObjectWithId {
is_insensitive?: boolean
+ document_count?: number
+
}
diff --git a/src-ui/src/app/data/paperless-correspondent.ts b/src-ui/src/app/data/paperless-correspondent.ts
index 217e62529..f2ff18525 100644
--- a/src-ui/src/app/data/paperless-correspondent.ts
+++ b/src-ui/src/app/data/paperless-correspondent.ts
@@ -1,8 +1,6 @@
import { MatchingModel } from './matching-model';
export interface PaperlessCorrespondent extends MatchingModel {
-
- document_count?: number
last_correspondence?: Date
diff --git a/src-ui/src/app/data/paperless-document-type.ts b/src-ui/src/app/data/paperless-document-type.ts
index d099bec47..b1d002461 100644
--- a/src-ui/src/app/data/paperless-document-type.ts
+++ b/src-ui/src/app/data/paperless-document-type.ts
@@ -2,6 +2,4 @@ import { MatchingModel } from './matching-model';
export interface PaperlessDocumentType extends MatchingModel {
- document_count?: number
-
}
diff --git a/src-ui/src/app/data/paperless-tag.ts b/src-ui/src/app/data/paperless-tag.ts
index b13653e91..45ef8a613 100644
--- a/src-ui/src/app/data/paperless-tag.ts
+++ b/src-ui/src/app/data/paperless-tag.ts
@@ -23,6 +23,5 @@ export interface PaperlessTag extends MatchingModel {
colour?: number
is_inbox_tag?: boolean
-
- document_count?: number
+
}
diff --git a/src-ui/src/app/pipes/filter.pipe.ts b/src-ui/src/app/pipes/filter.pipe.ts
index f799f40cc..d83ccc07a 100644
--- a/src-ui/src/app/pipes/filter.pipe.ts
+++ b/src-ui/src/app/pipes/filter.pipe.ts
@@ -1,10 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';
+import { ToggleableItem } from 'src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component';
+import { MatchingModel } from '../data/matching-model';
@Pipe({
name: 'filter'
})
export class FilterPipe implements PipeTransform {
- transform(items: any[], searchText: string): any[] {
+ transform(items: MatchingModel[], searchText: string): MatchingModel[] {
if (!items) return [];
if (!searchText) return items;
diff --git a/src-ui/src/app/services/rest/abstract-paperless-service.ts b/src-ui/src/app/services/rest/abstract-paperless-service.ts
index f57956754..8ad1a2141 100644
--- a/src-ui/src/app/services/rest/abstract-paperless-service.ts
+++ b/src-ui/src/app/services/rest/abstract-paperless-service.ts
@@ -74,27 +74,31 @@ export abstract class AbstractPaperlessService {
)
}
+ clearCache() {
+ this._listAll = null
+ }
+
get(id: number): Observable {
return this.http.get(this.getResourceUrl(id))
}
create(o: T): Observable {
- this._listAll = null
+ this.clearCache()
return this.http.post(this.getResourceUrl(), o)
}
delete(o: T): Observable {
- this._listAll = null
+ this.clearCache()
return this.http.delete(this.getResourceUrl(o.id))
}
update(o: T): Observable {
- this._listAll = null
+ this.clearCache()
return this.http.put(this.getResourceUrl(o.id), o)
}
patch(o: T): Observable {
- this._listAll = null
+ this.clearCache()
return this.http.patch(this.getResourceUrl(o.id), o)
}
diff --git a/src-ui/src/app/services/rest/document.service.ts b/src-ui/src/app/services/rest/document.service.ts
index ff17e972e..c42510270 100644
--- a/src-ui/src/app/services/rest/document.service.ts
+++ b/src-ui/src/app/services/rest/document.service.ts
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { PaperlessDocument } from 'src/app/data/paperless-document';
import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata';
import { AbstractPaperlessService } from './abstract-paperless-service';
-import { HttpClient } from '@angular/common/http';
+import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Results } from 'src/app/data/results';
import { FilterRule } from 'src/app/data/filter-rule';
@@ -22,6 +22,17 @@ export const DOCUMENT_SORT_FIELDS = [
{ field: 'modified', name: $localize`Modified` }
]
+export interface SelectionDataItem {
+ id: number
+ document_count: number
+}
+
+export interface SelectionData {
+ selected_correspondents: SelectionDataItem[]
+ selected_tags: SelectionDataItem[]
+ selected_document_types: SelectionDataItem[]
+}
+
@Injectable({
providedIn: 'root'
})
@@ -114,4 +125,8 @@ export class DocumentService extends AbstractPaperlessService
})
}
+ getSelectionData(ids: number[]): Observable {
+ return this.http.post(this.getResourceUrl(null, 'selection_data'), {"documents": ids})
+ }
+
}
diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py
index fd787f56a..c0c80a795 100644
--- a/src/documents/bulk_edit.py
+++ b/src/documents/bulk_edit.py
@@ -1,3 +1,5 @@
+import itertools
+
from django.db.models import Q
from django_q.tasks import async_task
from whoosh.writing import AsyncWriter
@@ -16,11 +18,7 @@ def set_correspondent(doc_ids, correspondent):
qs.update(correspondent=correspondent)
async_task(
- "documents.tasks.bulk_index_documents",
- document_ids=affected_docs
- )
-
- async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
+ "documents.tasks.bulk_update_documents", document_ids=affected_docs)
return "OK"
@@ -35,11 +33,7 @@ def set_document_type(doc_ids, document_type):
qs.update(document_type=document_type)
async_task(
- "documents.tasks.bulk_index_documents",
- document_ids=affected_docs
- )
-
- async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
+ "documents.tasks.bulk_update_documents", document_ids=affected_docs)
return "OK"
@@ -57,11 +51,7 @@ def add_tag(doc_ids, tag):
])
async_task(
- "documents.tasks.bulk_index_documents",
- document_ids=affected_docs
- )
-
- async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
+ "documents.tasks.bulk_update_documents", document_ids=affected_docs)
return "OK"
@@ -79,11 +69,29 @@ def remove_tag(doc_ids, tag):
).delete()
async_task(
- "documents.tasks.bulk_index_documents",
- document_ids=affected_docs
- )
+ "documents.tasks.bulk_update_documents", document_ids=affected_docs)
- async_task("documents.tasks.bulk_rename_files", document_ids=affected_docs)
+ return "OK"
+
+
+def modify_tags(doc_ids, add_tags, remove_tags):
+ qs = Document.objects.filter(id__in=doc_ids)
+ affected_docs = [doc.id for doc in qs]
+
+ DocumentTagRelationship = Document.tags.through
+
+ DocumentTagRelationship.objects.filter(
+ document_id__in=affected_docs,
+ tag_id__in=remove_tags,
+ ).delete()
+
+ DocumentTagRelationship.objects.bulk_create([DocumentTagRelationship(
+ document_id=doc, tag_id=tag) for (doc, tag) in itertools.product(
+ affected_docs, add_tags)
+ ], ignore_conflicts=True)
+
+ async_task(
+ "documents.tasks.bulk_update_documents", document_ids=affected_docs)
return "OK"
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index d9f1833bf..66f5f883f 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -217,6 +217,7 @@ class BulkEditSerializer(serializers.Serializer):
"set_document_type",
"add_tag",
"remove_tag",
+ "modify_tags",
"delete"
],
label="Method",
@@ -225,11 +226,31 @@ class BulkEditSerializer(serializers.Serializer):
parameters = serializers.DictField(allow_empty=True)
- def validate_documents(self, documents):
+ def _validate_document_id_list(self, documents, name="documents"):
+ if not type(documents) == list:
+ raise serializers.ValidationError(f"{name} must be a list")
+ if not all([type(i) == int for i in documents]):
+ raise serializers.ValidationError(
+ f"{name} must be a list of integers")
count = Document.objects.filter(id__in=documents).count()
if not count == len(documents):
raise serializers.ValidationError(
- "Some documents don't exist or were specified twice.")
+ f"Some documents in {name} don't exist or were "
+ f"specified twice.")
+
+ def _validate_tag_id_list(self, tags, name="tags"):
+ if not type(tags) == list:
+ raise serializers.ValidationError(f"{name} must be a list")
+ if not all([type(i) == int for i in tags]):
+ raise serializers.ValidationError(
+ f"{name} must be a list of integers")
+ count = Tag.objects.filter(id__in=tags).count()
+ if not count == len(tags):
+ raise serializers.ValidationError(
+ f"Some tags in {name} don't exist or were specified twice.")
+
+ def validate_documents(self, documents):
+ self._validate_document_id_list(documents)
return documents
def validate_method(self, method):
@@ -241,6 +262,8 @@ class BulkEditSerializer(serializers.Serializer):
return bulk_edit.add_tag
elif method == "remove_tag":
return bulk_edit.remove_tag
+ elif method == "modify_tags":
+ return bulk_edit.modify_tags
elif method == "delete":
return bulk_edit.delete
else:
@@ -283,6 +306,18 @@ class BulkEditSerializer(serializers.Serializer):
else:
raise serializers.ValidationError("correspondent not specified")
+ def _validate_parameters_modify_tags(self, parameters):
+ if "add_tags" in parameters:
+ self._validate_tag_id_list(parameters['add_tags'], "add_tags")
+ else:
+ raise serializers.ValidationError("add_tags not specified")
+
+ if "remove_tags" in parameters:
+ self._validate_tag_id_list(parameters['remove_tags'],
+ "remove_tags")
+ else:
+ raise serializers.ValidationError("remove_tags not specified")
+
def validate(self, attrs):
method = attrs['method']
@@ -294,6 +329,8 @@ class BulkEditSerializer(serializers.Serializer):
self._validate_parameters_document_type(parameters)
elif method == bulk_edit.add_tag or method == bulk_edit.remove_tag:
self._validate_parameters_tags(parameters)
+ elif method == bulk_edit.modify_tags:
+ self._validate_parameters_modify_tags(parameters)
return attrs
@@ -369,3 +406,11 @@ class PostDocumentSerializer(serializers.Serializer):
return [tag.id for tag in tags]
else:
return None
+
+
+class SelectionDataSerializer(serializers.Serializer):
+
+ documents = serializers.ListField(
+ required=True,
+ child=serializers.IntegerField()
+ )
diff --git a/src/documents/tasks.py b/src/documents/tasks.py
index c1f3ffbaa..f9937c177 100644
--- a/src/documents/tasks.py
+++ b/src/documents/tasks.py
@@ -90,16 +90,11 @@ def sanity_check():
return "No issues detected."
-def bulk_rename_files(document_ids):
- qs = Document.objects.filter(id__in=document_ids)
- for doc in qs:
- post_save.send(Document, instance=doc, created=False)
-
-
-def bulk_index_documents(document_ids):
+def bulk_update_documents(document_ids):
documents = Document.objects.filter(id__in=document_ids)
ix = index.open_index()
with AsyncWriter(ix) as writer:
for doc in documents:
index.update_document(writer, doc)
+ post_save.send(Document, instance=doc, created=False)
diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py
index 35b998a9d..adc947a1a 100644
--- a/src/documents/tests/test_api.py
+++ b/src/documents/tests/test_api.py
@@ -699,49 +699,63 @@ class TestBulkEdit(DirectoriesMixin, APITestCase):
self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1)
bulk_edit.set_correspondent([self.doc1.id, self.doc2.id, self.doc3.id], self.c2.id)
self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 3)
- self.assertEqual(self.async_task.call_count, 2)
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc1.id, self.doc2.id])
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc1.id, self.doc2.id])
+ self.async_task.assert_called_once()
+ args, kwargs = self.async_task.call_args
+ self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc2.id])
def test_unset_correspondent(self):
self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 1)
bulk_edit.set_correspondent([self.doc1.id, self.doc2.id, self.doc3.id], None)
self.assertEqual(Document.objects.filter(correspondent=self.c2).count(), 0)
- self.assertEqual(self.async_task.call_count, 2)
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc2.id, self.doc3.id])
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc2.id, self.doc3.id])
+ self.async_task.assert_called_once()
+ args, kwargs = self.async_task.call_args
+ self.assertCountEqual(kwargs['document_ids'], [self.doc2.id, self.doc3.id])
def test_set_document_type(self):
self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 1)
bulk_edit.set_document_type([self.doc1.id, self.doc2.id, self.doc3.id], self.dt2.id)
self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 3)
- self.assertEqual(self.async_task.call_count, 2)
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc1.id, self.doc2.id])
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc1.id, self.doc2.id])
+ self.async_task.assert_called_once()
+ args, kwargs = self.async_task.call_args
+ self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc2.id])
def test_unset_document_type(self):
self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 1)
bulk_edit.set_document_type([self.doc1.id, self.doc2.id, self.doc3.id], None)
self.assertEqual(Document.objects.filter(document_type=self.dt2).count(), 0)
- self.assertEqual(self.async_task.call_count, 2)
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc2.id, self.doc3.id])
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc2.id, self.doc3.id])
+ self.async_task.assert_called_once()
+ args, kwargs = self.async_task.call_args
+ self.assertCountEqual(kwargs['document_ids'], [self.doc2.id, self.doc3.id])
def test_add_tag(self):
self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2)
bulk_edit.add_tag([self.doc1.id, self.doc2.id, self.doc3.id, self.doc4.id], self.t1.id)
self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 4)
- self.assertEqual(self.async_task.call_count, 2)
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc1.id, self.doc3.id])
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc1.id, self.doc3.id])
+ self.async_task.assert_called_once()
+ args, kwargs = self.async_task.call_args
+ self.assertCountEqual(kwargs['document_ids'], [self.doc1.id, self.doc3.id])
def test_remove_tag(self):
self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 2)
bulk_edit.remove_tag([self.doc1.id, self.doc3.id, self.doc4.id], self.t1.id)
self.assertEqual(Document.objects.filter(tags__id=self.t1.id).count(), 1)
- self.assertEqual(self.async_task.call_count, 2)
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc4.id])
- self.assertCountEqual(self.async_task.call_args_list[0][1]['document_ids'], [self.doc4.id])
+ self.async_task.assert_called_once()
+ args, kwargs = self.async_task.call_args
+ self.assertCountEqual(kwargs['document_ids'], [self.doc4.id])
+
+ def test_modify_tags(self):
+ tag_unrelated = Tag.objects.create(name="unrelated")
+ self.doc2.tags.add(tag_unrelated)
+ self.doc3.tags.add(tag_unrelated)
+ bulk_edit.modify_tags([self.doc2.id, self.doc3.id], add_tags=[self.t2.id], remove_tags=[self.t1.id])
+
+ self.assertCountEqual(list(self.doc2.tags.all()), [self.t2, tag_unrelated])
+ self.assertCountEqual(list(self.doc3.tags.all()), [self.t2, tag_unrelated])
+
+ self.async_task.assert_called_once()
+ args, kwargs = self.async_task.call_args
+ # TODO: doc3 should not be affected, but the query for that is rather complicated
+ self.assertCountEqual(kwargs['document_ids'], [self.doc2.id, self.doc3.id])
def test_delete(self):
self.assertEqual(Document.objects.count(), 5)
diff --git a/src/documents/views.py b/src/documents/views.py
index 8f6ec7f13..32b88a18f 100755
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -4,7 +4,8 @@ from datetime import datetime
from time import mktime
from django.conf import settings
-from django.db.models import Count, Max
+from django.db.models import Count, Max, Case, When, IntegerField
+from django.db.models.functions import Lower
from django.http import HttpResponse, HttpResponseBadRequest, Http404
from django.views.decorators.cache import cache_control
from django.views.generic import TemplateView
@@ -48,7 +49,7 @@ from .serialisers import (
DocumentTypeSerializer,
PostDocumentSerializer,
SavedViewSerializer,
- BulkEditSerializer
+ BulkEditSerializer, SelectionDataSerializer
)
@@ -68,7 +69,7 @@ class CorrespondentViewSet(ModelViewSet):
queryset = Correspondent.objects.annotate(
document_count=Count('documents'),
- last_correspondence=Max('documents__created')).order_by('name')
+ last_correspondence=Max('documents__created')).order_by(Lower('name'))
serializer_class = CorrespondentSerializer
pagination_class = StandardPagination
@@ -87,7 +88,7 @@ class TagViewSet(ModelViewSet):
model = Tag
queryset = Tag.objects.annotate(
- document_count=Count('documents')).order_by('name')
+ document_count=Count('documents')).order_by(Lower('name'))
serializer_class = TagSerializer
pagination_class = StandardPagination
@@ -101,7 +102,7 @@ class DocumentTypeViewSet(ModelViewSet):
model = DocumentType
queryset = DocumentType.objects.annotate(
- document_count=Count('documents')).order_by('name')
+ document_count=Count('documents')).order_by(Lower('name'))
serializer_class = DocumentTypeSerializer
pagination_class = StandardPagination
@@ -372,6 +373,63 @@ class PostDocumentView(APIView):
return Response("OK")
+class SelectionDataView(APIView):
+
+ permission_classes = (IsAuthenticated,)
+ serializer_class = SelectionDataSerializer
+ parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
+
+ def get_serializer_context(self):
+ return {
+ 'request': self.request,
+ 'format': self.format_kwarg,
+ 'view': self
+ }
+
+ def get_serializer(self, *args, **kwargs):
+ kwargs['context'] = self.get_serializer_context()
+ return self.serializer_class(*args, **kwargs)
+
+ def post(self, request, format=None):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ ids = serializer.validated_data.get('documents')
+
+ correspondents = Correspondent.objects.annotate(
+ document_count=Count(Case(
+ When(documents__id__in=ids, then=1),
+ output_field=IntegerField()
+ )))
+
+ tags = Tag.objects.annotate(document_count=Count(Case(
+ When(documents__id__in=ids, then=1),
+ output_field=IntegerField()
+ )))
+
+ types = DocumentType.objects.annotate(document_count=Count(Case(
+ When(documents__id__in=ids, then=1),
+ output_field=IntegerField()
+ )))
+
+ r = Response({
+ "selected_correspondents": [{
+ "id": t.id,
+ "document_count": t.document_count
+ } for t in correspondents],
+ "selected_tags": [{
+ "id": t.id,
+ "document_count": t.document_count
+ } for t in tags],
+ "selected_document_types": [{
+ "id": t.id,
+ "document_count": t.document_count
+ } for t in types]
+ })
+
+ return r
+
+
class SearchView(APIView):
permission_classes = (IsAuthenticated,)
diff --git a/src/paperless/urls.py b/src/paperless/urls.py
index 831eb02b4..39e99a7a4 100755
--- a/src/paperless/urls.py
+++ b/src/paperless/urls.py
@@ -19,7 +19,8 @@ from documents.views import (
StatisticsView,
PostDocumentView,
SavedViewViewSet,
- BulkEditView
+ BulkEditView,
+ SelectionDataView
)
from paperless.views import FaviconView
@@ -53,10 +54,12 @@ urlpatterns = [
re_path(r"^documents/post_document/", PostDocumentView.as_view(),
name="post_document"),
-
re_path(r"^documents/bulk_edit/", BulkEditView.as_view(),
name="bulk_edit"),
+ re_path(r"^documents/selection_data/", SelectionDataView.as_view(),
+ name="selection_data"),
+
path('token/', views.obtain_auth_token)
] + api_router.urls)),
diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py
index 3c200362d..537807400 100644
--- a/src/paperless_mail/mail.py
+++ b/src/paperless_mail/mail.py
@@ -3,9 +3,9 @@ import tempfile
from datetime import timedelta, date
import magic
+import pathvalidate
from django.conf import settings
from django.db import DatabaseError
-from django.utils.text import slugify
from django_q.tasks import async_task
from imap_tools import MailBox, MailBoxUnencrypted, AND, MailMessageFlags, \
MailboxFolderSelectError
@@ -294,7 +294,7 @@ class MailAccountHandler(LoggingMixin):
async_task(
"documents.tasks.consume_file",
path=temp_filename,
- override_filename=att.filename,
+ override_filename=pathvalidate.sanitize_filename(att.filename), # NOQA: E501
override_title=title,
override_correspondent_id=correspondent.id if correspondent else None, # NOQA: E501
override_document_type_id=doc_type.id if doc_type else None, # NOQA: E501