|
- {{ document.title }} |
+
+ {{ document.title }}
+
+
+
+ |
{{ getDaysRemaining(document) }} days |
diff --git a/src-ui/src/app/components/common/preview-popup/preview-popup.component.html b/src-ui/src/app/components/common/preview-popup/preview-popup.component.html
index f9a8b9771..18b7cb94d 100644
--- a/src-ui/src/app/components/common/preview-popup/preview-popup.component.html
+++ b/src-ui/src/app/components/common/preview-popup/preview-popup.component.html
@@ -1,30 +1,37 @@
-
+
diff --git a/src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts b/src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts
index 2b9f71cef..12021fc90 100644
--- a/src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts
+++ b/src-ui/src/app/components/common/preview-popup/preview-popup.component.spec.ts
@@ -1,4 +1,9 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing'
+import {
+ ComponentFixture,
+ fakeAsync,
+ TestBed,
+ tick,
+} from '@angular/core/testing'
import { PreviewPopupComponent } from './preview-popup.component'
import { By } from '@angular/platform-browser'
@@ -15,6 +20,8 @@ import {
withInterceptorsFromDi,
} from '@angular/common/http'
import { of, throwError } from 'rxjs'
+import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
+import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
const doc = {
id: 10,
@@ -34,8 +41,12 @@ describe('PreviewPopupComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
- declarations: [PreviewPopupComponent, SafeUrlPipe],
- imports: [NgxBootstrapIconsModule.pick(allIcons), PdfViewerModule],
+ declarations: [PreviewPopupComponent, SafeUrlPipe, DocumentTitlePipe],
+ imports: [
+ NgxBootstrapIconsModule.pick(allIcons),
+ PdfViewerModule,
+ NgbPopoverModule,
+ ],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
@@ -70,12 +81,14 @@ describe('PreviewPopupComponent', () => {
it('should render object if native PDF viewer enabled', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
+ component.popover.open()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
})
it('should render pngx viewer if native PDF viewer disabled', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
+ component.popover.open()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
@@ -83,6 +96,7 @@ describe('PreviewPopupComponent', () => {
it('should show lock icon on password error', () => {
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
+ component.popover.open()
component.onError({ name: 'PasswordException' })
fixture.detectChanges()
expect(component.requiresPassword).toBeTruthy()
@@ -93,16 +107,18 @@ describe('PreviewPopupComponent', () => {
component.document.original_file_name = 'sample.png'
component.document.mime_type = 'image/png'
component.document.archived_file_name = undefined
+ component.popover.open()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
})
it('should show message on error', () => {
+ component.popover.open()
component.onError({})
fixture.detectChanges()
- expect(fixture.debugElement.nativeElement.textContent).toContain(
- 'Error loading preview'
- )
+ expect(
+ fixture.debugElement.query(By.css('.popover')).nativeElement.textContent
+ ).toContain('Error loading preview')
})
it('should get text content from http if appropriate', () => {
@@ -122,4 +138,17 @@ describe('PreviewPopupComponent', () => {
component.init()
expect(component.previewText).toEqual('Preview text')
})
+
+ it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
+ component.mouseEnterPreview()
+ expect(component.popover.isOpen()).toBeTruthy()
+ tick(600)
+ component.close()
+
+ component.mouseEnterPreview()
+ tick(100)
+ component.mouseLeavePreview()
+ tick(600)
+ expect(component.popover.isOpen()).toBeFalsy()
+ }))
})
diff --git a/src-ui/src/app/components/common/preview-popup/preview-popup.component.ts b/src-ui/src/app/components/common/preview-popup/preview-popup.component.ts
index 6d2ede266..75f3cbb86 100644
--- a/src-ui/src/app/components/common/preview-popup/preview-popup.component.ts
+++ b/src-ui/src/app/components/common/preview-popup/preview-popup.component.ts
@@ -1,5 +1,6 @@
import { HttpClient } from '@angular/common/http'
-import { Component, Input, OnDestroy } from '@angular/core'
+import { Component, Input, OnDestroy, ViewChild } from '@angular/core'
+import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { first, Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
@@ -23,6 +24,18 @@ export class PreviewPopupComponent implements OnDestroy {
return this._document
}
+ @Input()
+ link: string
+
+ @Input()
+ linkClasses: string = 'btn btn-sm btn-outline-secondary'
+
+ @Input()
+ linkTarget: string = '_blank'
+
+ @Input()
+ linkTitle: string = $localize`Open preview`
+
unsubscribeNotifier: Subject = new Subject()
error = false
@@ -31,6 +44,12 @@ export class PreviewPopupComponent implements OnDestroy {
previewText: string
+ @ViewChild('popover') popover: NgbPopover
+
+ mouseOnPreview: boolean
+
+ popoverClass: string = 'shadow popover-preview'
+
get renderAsObject(): boolean {
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
}
@@ -83,4 +102,33 @@ export class PreviewPopupComponent implements OnDestroy {
this.error = true
}
}
+
+ get previewUrl() {
+ return this.documentService.getPreviewUrl(this.document.id)
+ }
+
+ mouseEnterPreview() {
+ this.mouseOnPreview = true
+ if (!this.popover.isOpen()) {
+ // we're going to open but hide to pre-load content during hover delay
+ this.popover.open()
+ this.popoverClass = 'shadow popover-preview pe-none opacity-0'
+ setTimeout(() => {
+ if (this.mouseOnPreview) {
+ // show popover
+ this.popoverClass = this.popoverClass.replace('pe-none opacity-0', '')
+ } else {
+ this.popover.close()
+ }
+ }, 600)
+ }
+ }
+
+ mouseLeavePreview() {
+ this.mouseOnPreview = false
+ }
+
+ public close() {
+ this.popover.close(false)
+ }
}
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 04f3a236a..34557be31 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
@@ -1,4 +1,4 @@
-
+
![]()
@@ -56,14 +56,9 @@
Open
-
+
View
-
-
-
-
+
Download
diff --git a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts
index efd5076be..95b12d7ec 100644
--- a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts
+++ b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.spec.ts
@@ -1,11 +1,6 @@
import { DatePipe } from '@angular/common'
import { provideHttpClientTesting } from '@angular/common/http/testing'
-import {
- ComponentFixture,
- TestBed,
- fakeAsync,
- tick,
-} from '@angular/core/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import {
@@ -84,21 +79,6 @@ describe('DocumentCardLargeComponent', () => {
expect(fixture.nativeElement.textContent).toContain('8 pages')
})
- it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
- component.mouseEnterPreview()
- expect(component.popover.isOpen()).toBeTruthy()
- expect(component.popoverHidden).toBeTruthy()
- tick(600)
- expect(component.popoverHidden).toBeFalsy()
- component.mouseLeaveCard()
-
- component.mouseEnterPreview()
- tick(100)
- component.mouseLeavePreview()
- tick(600)
- expect(component.popover.isOpen()).toBeFalsy()
- }))
-
it('should trim content', () => {
expect(component.contentTrimmed).toHaveLength(503) // includes ...
})
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 a3d57d950..99597ca5a 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
@@ -12,9 +12,9 @@ import {
} from 'src/app/data/document'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
-import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
@Component({
selector: 'pngx-document-card-large',
@@ -65,7 +65,7 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
@Output()
clickMoreLike = new EventEmitter()
- @ViewChild('popover') popover: NgbPopover
+ @ViewChild('popupPreview') popupPreview: PreviewPopupComponent
mouseOnPreview = false
popoverHidden = true
@@ -112,29 +112,8 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
return this.documentService.getPreviewUrl(this.document.id)
}
- mouseEnterPreview() {
- this.mouseOnPreview = true
- if (!this.popover.isOpen()) {
- // we're going to open but hide to pre-load content during hover delay
- this.popover.open()
- this.popoverHidden = true
- setTimeout(() => {
- if (this.mouseOnPreview) {
- // show popover
- this.popoverHidden = false
- } else {
- this.popover.close()
- }
- }, 600)
- }
- }
-
- mouseLeavePreview() {
- this.mouseOnPreview = false
- }
-
mouseLeaveCard() {
- this.popover.close()
+ this.popupPreview.close()
}
get contentTrimmed() {
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 57bd6048b..60713ef02 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
@@ -1,5 +1,5 @@
-
+
![]()
@@ -129,14 +129,9 @@
-
+
-
-
-
-
+
diff --git a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts
index b86453a25..0c0c82103 100644
--- a/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts
+++ b/src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts
@@ -1,11 +1,6 @@
import { DatePipe } from '@angular/common'
import { provideHttpClientTesting } from '@angular/common/http/testing'
-import {
- ComponentFixture,
- TestBed,
- fakeAsync,
- tick,
-} from '@angular/core/testing'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import {
NgbPopoverModule,
@@ -116,19 +111,4 @@ describe('DocumentCardSmallComponent', () => {
fixture.debugElement.queryAll(By.directive(TagComponent))
).toHaveLength(6)
})
-
- it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
- component.mouseEnterPreview()
- expect(component.popover.isOpen()).toBeTruthy()
- expect(component.popoverHidden).toBeTruthy()
- tick(600)
- expect(component.popoverHidden).toBeFalsy()
- component.mouseLeaveCard()
-
- component.mouseEnterPreview()
- tick(100)
- component.mouseLeavePreview()
- tick(600)
- expect(component.popover.isOpen()).toBeFalsy()
- }))
})
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 5cd583fb0..7397159af 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
@@ -13,9 +13,9 @@ import {
} from 'src/app/data/document'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
-import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
@Component({
selector: 'pngx-document-card-small',
@@ -61,10 +61,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
moreTags: number = null
- @ViewChild('popover') popover: NgbPopover
-
- mouseOnPreview = false
- popoverHidden = true
+ @ViewChild('popupPreview') popupPreview: PreviewPopupComponent
getIsThumbInverted() {
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
@@ -78,10 +75,6 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
return this.documentService.getDownloadUrl(this.document.id)
}
- get previewUrl() {
- return this.documentService.getPreviewUrl(this.document.id)
- }
-
get privateName() {
return $localize`Private`
}
@@ -100,29 +93,8 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
)
}
- mouseEnterPreview() {
- this.mouseOnPreview = true
- if (!this.popover.isOpen()) {
- // we're going to open but hide to pre-load content during hover delay
- this.popover.open()
- this.popoverHidden = true
- setTimeout(() => {
- if (this.mouseOnPreview) {
- // show popover
- this.popoverHidden = false
- } else {
- this.popover.close()
- }
- }, 600)
- }
- }
-
- mouseLeavePreview() {
- this.mouseOnPreview = false
- }
-
mouseLeaveCard() {
- this.popover.close()
+ this.popupPreview.close()
}
get notesEnabled(): boolean {
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 4eb9d179e..ebe3536e5 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
@@ -292,7 +292,12 @@
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
- {{d.title | documentTitle}}
+
}
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
@for (t of d.tags$ | async; track t) {
diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss
index 331f6e6d8..fe1466d58 100644
--- a/src-ui/src/styles.scss
+++ b/src-ui/src/styles.scss
@@ -564,11 +564,6 @@ table.table {
}
}
-.popover-hidden .popover {
- opacity: 0;
- pointer-events: none;
-}
-
// Tour
.tour-active .popover {
min-width: 360px;
@@ -728,3 +723,27 @@ i-bs svg {
vertical-align: middle;
}
}
+
+// fixes for buttons in preview popup
+.btn-group pngx-preview-popup:not(:last-child) {
+ // Prevent double borders when buttons are next to each other
+ > .btn {
+ margin-left: calc(#{$btn-border-width} * -1);
+ }
+ > .btn {
+ @include border-end-radius(0);
+ }
+}
+.btn-group pngx-preview-popup:not(:first-child) {
+ > .btn {
+ @include border-start-radius(0);
+ }
+}
+.btn-group pngx-preview-popup {
+ position: relative;
+ flex: 1 1 auto;
+
+ > .btn {
+ display: block;
+ }
+}
diff --git a/src/documents/views.py b/src/documents/views.py
index 35fa8eafc..367559c6d 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -426,7 +426,7 @@ class DocumentViewSet(
)
def file_response(self, pk, request, disposition):
- doc = Document.objects.select_related("owner").get(id=pk)
+ doc = Document.global_objects.select_related("owner").get(id=pk)
if request.user is not None and not has_perms_owner_aware(
request.user,
"view_document",
From 961452803322e733c19173507c09eb9aa5c7c7a5 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Fri, 29 Nov 2024 22:36:40 -0800
Subject: [PATCH 14/23] Enhancement: filterable list count sorting and
opacification (#8386)
---
src-ui/messages.xlf | 74 +++++++++----------
.../filterable-dropdown.component.html | 10 ++-
.../filterable-dropdown.component.spec.ts | 31 ++++++++
.../filterable-dropdown.component.ts | 29 +++++++-
.../toggleable-dropdown-button.component.html | 9 ++-
.../toggleable-dropdown-button.component.ts | 7 ++
6 files changed, 116 insertions(+), 44 deletions(-)
diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index bd8b89095..33e9aacb8 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -2300,7 +2300,7 @@
src/app/components/document-detail/document-detail.component.ts
- 846
+ 847
@@ -2577,19 +2577,19 @@
src/app/components/document-detail/document-detail.component.ts
- 870
+ 871
src/app/components/document-detail/document-detail.component.ts
- 1169
+ 1170
src/app/components/document-detail/document-detail.component.ts
- 1207
+ 1208
src/app/components/document-detail/document-detail.component.ts
- 1248
+ 1249
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -3172,7 +3172,7 @@
src/app/components/document-detail/document-detail.component.ts
- 823
+ 824
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -4901,7 +4901,7 @@
Create
src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
- 50
+ 58
src/app/components/common/share-links-dropdown/share-links-dropdown.component.html
@@ -4928,21 +4928,21 @@
Apply
src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
- 56
+ 64
Click again to exclude items.
src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
- 63
+ 71
Not assigned
src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
- 351
+ 370
Filter drop down element to filter for documents with no correspondent/type/tag assigned
@@ -4950,7 +4950,7 @@
Open filter
src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
- 463
+ 486
@@ -6209,7 +6209,7 @@
src/app/components/document-detail/document-detail.component.ts
- 1225
+ 1226
src/app/guards/dirty-saved-view.guard.ts
@@ -6573,36 +6573,36 @@
Document saved successfully.
src/app/components/document-detail/document-detail.component.ts
- 737
+ 738
src/app/components/document-detail/document-detail.component.ts
- 751
+ 752
Error saving document
src/app/components/document-detail/document-detail.component.ts
- 755
+ 756
src/app/components/document-detail/document-detail.component.ts
- 796
+ 797
Do you really want to move the document "" to the trash?
src/app/components/document-detail/document-detail.component.ts
- 824
+ 825
Documents can be restored prior to permanent deletion.
src/app/components/document-detail/document-detail.component.ts
- 825
+ 826
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -6613,7 +6613,7 @@
Move to trash
src/app/components/document-detail/document-detail.component.ts
- 827
+ 828
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -6624,7 +6624,7 @@
Reprocess confirm
src/app/components/document-detail/document-detail.component.ts
- 866
+ 867
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -6635,70 +6635,70 @@
This operation will permanently recreate the archive file for this document.
src/app/components/document-detail/document-detail.component.ts
- 867
+ 868
The archive file will be re-generated with the current settings.
src/app/components/document-detail/document-detail.component.ts
- 868
+ 869
Reprocess operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.
src/app/components/document-detail/document-detail.component.ts
- 878
+ 879
Error executing operation
src/app/components/document-detail/document-detail.component.ts
- 889
+ 890
Page Fit
src/app/components/document-detail/document-detail.component.ts
- 962
+ 963
Split confirm
src/app/components/document-detail/document-detail.component.ts
- 1167
+ 1168
This operation will split the selected document(s) into new documents.
src/app/components/document-detail/document-detail.component.ts
- 1168
+ 1169
Split operation will begin in the background.
src/app/components/document-detail/document-detail.component.ts
- 1184
+ 1185
Error executing split operation
src/app/components/document-detail/document-detail.component.ts
- 1193
+ 1194
Rotate confirm
src/app/components/document-detail/document-detail.component.ts
- 1205
+ 1206
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -6709,49 +6709,49 @@
This operation will permanently rotate the original version of the current document.
src/app/components/document-detail/document-detail.component.ts
- 1206
+ 1207
Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.
src/app/components/document-detail/document-detail.component.ts
- 1222
+ 1223
Error executing rotate operation
src/app/components/document-detail/document-detail.component.ts
- 1234
+ 1235
Delete pages confirm
src/app/components/document-detail/document-detail.component.ts
- 1246
+ 1247
This operation will permanently delete the selected pages from the original document.
src/app/components/document-detail/document-detail.component.ts
- 1247
+ 1248
Delete pages operation will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.
src/app/components/document-detail/document-detail.component.ts
- 1262
+ 1263
Error executing delete pages operation
src/app/components/document-detail/document-detail.component.ts
- 1271
+ 1272
diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
index a3b49cf62..28ce03ad6 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
+++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
@@ -38,7 +38,15 @@
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
@if (allowSelectNone || item.id) {
+ [item]="item"
+ [hideCount]="hideCount(item)"
+ [opacifyCount]="!editing"
+ [state]="selectionModel.get(item.id)"
+ [count]="getUpdatedDocumentCount(item.id)"
+ (toggled)="selectionModel.toggle(item.id)"
+ (exclude)="excludeClicked(item.id)"
+ (click)="setButtonItemIndex(i - 1)"
+ [disabled]="disabled">
}
}
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 0e2999742..78af75607 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
@@ -509,6 +509,37 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
])
})
+ it('selection model should sort items by state and document counts, if set', () => {
+ component.items = items.concat([{ id: 4, name: 'Item D' }])
+ component.selectionModel = selectionModel
+ component.documentCounts = [
+ { id: 1, document_count: 0 }, // Tag1
+ { id: 2, document_count: 1 }, // Tag2
+ { id: 4, document_count: 2 },
+ ]
+ component.selectionModel.apply()
+ expect(selectionModel.items).toEqual([
+ nullItem,
+ { id: 4, name: 'Item D' },
+ items[1], // Tag2
+ items[0], // Tag1
+ ])
+
+ selectionModel.toggle(items[1].id)
+ component.documentCounts = [
+ { id: 1, document_count: 0 },
+ { id: 2, document_count: 1 },
+ { id: 4, document_count: 0 },
+ ]
+ selectionModel.apply()
+ expect(selectionModel.items).toEqual([
+ nullItem,
+ items[1], // Tag2
+ { id: 4, name: 'Item D' },
+ items[0], // Tag1
+ ])
+ })
+
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
component.items = items
component.icon = 'tag-fill'
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 a23d413d7..2351dc0da 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
@@ -43,6 +43,11 @@ export class FilterableDropdownSelectionModel {
private _intersection: Intersection = Intersection.Include
temporaryIntersection: Intersection = this._intersection
+ private _documentCounts: SelectionDataItem[] = []
+ public set documentCounts(counts: SelectionDataItem[]) {
+ this._documentCounts = counts
+ }
+
private _items: MatchingModel[] = []
get items(): MatchingModel[] {
return this._items
@@ -69,6 +74,16 @@ export class FilterableDropdownSelectionModel {
this.getNonTemporary(b.id) == ToggleableItemState.NotSelected
) {
return -1
+ } else if (
+ this._documentCounts.length &&
+ this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
+ ) {
+ return -1
+ } else if (
+ this._documentCounts.length &&
+ this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
+ ) {
+ return 1
} else {
return a.name.localeCompare(b.name)
}
@@ -286,6 +301,10 @@ export class FilterableDropdownSelectionModel {
)
}
+ getDocumentCount(id: number) {
+ return this._documentCounts.find((c) => c.id === id)?.document_count
+ }
+
init(map: Map) {
this.temporarySelectionStates = map
this.apply()
@@ -431,7 +450,11 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
}
@Input()
- documentCounts: SelectionDataItem[]
+ set documentCounts(counts: SelectionDataItem[]) {
+ if (counts) {
+ this.selectionModel.documentCounts = counts
+ }
+ }
@Input()
shortcutKey: string
@@ -544,9 +567,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
}
getUpdatedDocumentCount(id: number) {
- if (this.documentCounts) {
- return this.documentCounts.find((c) => c.id === id)?.document_count
- }
+ return this.selectionModel.getDocumentCount(id)
}
listKeyDown(event: KeyboardEvent) {
diff --git a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.html b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.html
index 348393ced..1c7dad499 100644
--- a/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.html
+++ b/src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.html
@@ -1,4 +1,9 @@
-
@@ -6209,7 +6209,7 @@
src/app/components/document-detail/document-detail.component.ts
- 1226
+ 1228
src/app/guards/dirty-saved-view.guard.ts
@@ -6670,88 +6670,88 @@
Split confirm
src/app/components/document-detail/document-detail.component.ts
- 1168
+ 1169
This operation will split the selected document(s) into new documents.
src/app/components/document-detail/document-detail.component.ts
- 1169
+ 1170
Split operation will begin in the background.
src/app/components/document-detail/document-detail.component.ts
- 1185
+ 1186
Error executing split operation
src/app/components/document-detail/document-detail.component.ts
- 1194
+ 1195
Rotate confirm
src/app/components/document-detail/document-detail.component.ts
- 1206
+ 1208
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
- 787
+ 788
This operation will permanently rotate the original version of the current document.
src/app/components/document-detail/document-detail.component.ts
- 1207
+ 1209
Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.
src/app/components/document-detail/document-detail.component.ts
- 1223
+ 1225
Error executing rotate operation
src/app/components/document-detail/document-detail.component.ts
- 1235
+ 1237
Delete pages confirm
src/app/components/document-detail/document-detail.component.ts
- 1247
+ 1249
This operation will permanently delete the selected pages from the original document.
src/app/components/document-detail/document-detail.component.ts
- 1248
+ 1250
Delete pages operation will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.
src/app/components/document-detail/document-detail.component.ts
- 1263
+ 1265
Error executing delete pages operation
src/app/components/document-detail/document-detail.component.ts
- 1272
+ 1274
@@ -7096,13 +7096,6 @@
This operation will permanently rotate the original version of document(s).
-
- src/app/components/document-list/bulk-editor/bulk-editor.component.ts
- 788
-
-
-
- This will alter the original copy.
src/app/components/document-list/bulk-editor/bulk-editor.component.ts
789
diff --git a/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.scss b/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.scss
index f74de973d..4ddd79bfa 100644
--- a/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.scss
+++ b/src-ui/src/app/components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component.scss
@@ -1,6 +1,6 @@
.pdf-viewer-container {
background-color: gray;
- height: 350px;
+ height: 550px;
pdf-viewer {
width: 100%;
diff --git a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html
index 7fb68218a..47e4c137c 100644
--- a/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html
+++ b/src-ui/src/app/components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component.html
@@ -6,7 +6,7 @@
{{message}}
-
+
-
+
-
+
+
-
}
+ @case (ContentRenderType.TIFF) {
+ @if (!tiffError) {
+
+ ![{{title}}]()
+
+ } @else {
+ {{tiffError}}
+ }
+ }
@case (ContentRenderType.Other) {
}
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.scss b/src-ui/src/app/components/document-detail/document-detail.component.scss
index f61e20e83..e3d17476b 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.scss
+++ b/src-ui/src/app/components/document-detail/document-detail.component.scss
@@ -61,6 +61,7 @@ textarea.rtl {
width: 100%;
height: 100%;
object-fit: contain;
+ object-position: top;
}
.thumb-preview {
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 41a576f01..46b72cb4e 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
@@ -1270,4 +1270,46 @@ describe('DocumentDetailComponent', () => {
expect(component.createDisabled(DataType.StoragePath)).toBeFalsy()
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
})
+
+ it('should call tryRenderTiff when no archive and file is tiff', () => {
+ initNormally()
+ const tiffRenderSpy = jest.spyOn(
+ DocumentDetailComponent.prototype as any,
+ 'tryRenderTiff'
+ )
+ const doc = Object.assign({}, component.document)
+ doc.archived_file_name = null
+ doc.mime_type = 'image/tiff'
+ jest
+ .spyOn(documentService, 'getMetadata')
+ .mockReturnValue(
+ of({ has_archive_version: false, original_mime_type: 'image/tiff' })
+ )
+ component.updateComponent(doc)
+ fixture.detectChanges()
+ expect(component.archiveContentRenderType).toEqual(
+ component.ContentRenderType.TIFF
+ )
+ expect(tiffRenderSpy).toHaveBeenCalled()
+ })
+
+ it('should try to render tiff and show error if failed', () => {
+ initNormally()
+ // just the text request
+ httpTestingController.expectOne(component.previewUrl)
+
+ // invalid tiff
+ component['tryRenderTiff']()
+ httpTestingController
+ .expectOne(component.previewUrl)
+ .flush(new ArrayBuffer(100)) // arraybuffer
+ expect(component.tiffError).not.toBeUndefined()
+
+ // http error
+ component['tryRenderTiff']()
+ httpTestingController
+ .expectOne(component.previewUrl)
+ .error(new ErrorEvent('failed'))
+ expect(component.tiffError).not.toBeUndefined()
+ })
})
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 2842509fc..f1afd95c0 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
@@ -72,6 +72,7 @@ import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/dele
import { HotKeyService } from 'src/app/services/hot-key.service'
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
import { DataType } from 'src/app/data/datatype'
+import * as UTIF from 'utif'
enum DocumentDetailNavIDs {
Details = 1,
@@ -89,6 +90,7 @@ enum ContentRenderType {
Text = 'text',
Other = 'other',
Unknown = 'unknown',
+ TIFF = 'tiff',
}
enum ZoomSetting {
@@ -136,6 +138,8 @@ export class DocumentDetailComponent
downloadUrl: string
downloadOriginalUrl: string
previewLoaded: boolean = false
+ tiffURL: string
+ tiffError: string
correspondents: Correspondent[]
documentTypes: DocumentType[]
@@ -244,6 +248,8 @@ export class DocumentDetailComponent
['text/plain', 'application/csv', 'text/csv'].includes(mimeType)
) {
return ContentRenderType.Text
+ } else if (mimeType.indexOf('tiff') >= 0) {
+ return ContentRenderType.TIFF
} else if (mimeType?.indexOf('image/') === 0) {
return ContentRenderType.Image
}
@@ -542,6 +548,9 @@ export class DocumentDetailComponent
this.document = doc
this.requiresPassword = false
this.updateFormForCustomFields()
+ if (this.archiveContentRenderType === ContentRenderType.TIFF) {
+ this.tryRenderTiff()
+ }
this.documentsService
.getMetadata(doc.id)
.pipe(
@@ -1278,4 +1287,45 @@ export class DocumentDetailComponent
})
})
}
+
+ private tryRenderTiff() {
+ this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
+ next: (res) => {
+ /* istanbul ignore next */
+ try {
+ // See UTIF.js > _imgLoaded
+ const tiffIfds: any[] = UTIF.decode(res)
+ var vsns = tiffIfds,
+ ma = 0,
+ page = vsns[0]
+ if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
+ for (var i = 0; i < vsns.length; i++) {
+ var img = vsns[i]
+ if (img['t258'] == null || img['t258'].length < 3) continue
+ var ar = img['t256'] * img['t257']
+ if (ar > ma) {
+ ma = ar
+ page = img
+ }
+ }
+ UTIF.decodeImage(res, page, tiffIfds)
+ const rgba = UTIF.toRGBA8(page)
+ const { width: w, height: h } = page
+ var cnv = document.createElement('canvas')
+ cnv.width = w
+ cnv.height = h
+ var ctx = cnv.getContext('2d'),
+ imgd = ctx.createImageData(w, h)
+ for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
+ ctx.putImageData(imgd, 0, 0)
+ this.tiffURL = cnv.toDataURL()
+ } catch (err) {
+ this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
+ }
+ },
+ error: (err) => {
+ this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
+ },
+ })
+ }
}
From 00485138f9e8aad5076c8804905b38961c882114 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 1 Dec 2024 18:56:54 -0800
Subject: [PATCH 20/23] Chore(deps-dev): Bump the development group with 4
updates (#8352)
Bumps the development group with 4 updates: [ruff](https://github.com/astral-sh/ruff), [pytest-httpx](https://github.com/Colin-b/pytest_httpx), [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material).
Updates `ruff` from 0.7.3 to 0.8.0
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.7.3...0.8.0)
Updates `pytest-httpx` from 0.33.0 to 0.34.0
- [Release notes](https://github.com/Colin-b/pytest_httpx/releases)
- [Changelog](https://github.com/Colin-b/pytest_httpx/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/Colin-b/pytest_httpx/compare/v0.33.0...v0.34.0)
Updates `pytest-rerunfailures` from 14.0 to 15.0
- [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst)
- [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/14.0...15.0)
Updates `mkdocs-material` from 9.5.44 to 9.5.46
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.44...9.5.46)
---
updated-dependencies:
- dependency-name: ruff
dependency-type: direct:development
update-type: version-update:semver-minor
dependency-group: development
- dependency-name: pytest-httpx
dependency-type: direct:development
update-type: version-update:semver-minor
dependency-group: development
- dependency-name: pytest-rerunfailures
dependency-type: direct:development
update-type: version-update:semver-major
dependency-group: development
- dependency-name: mkdocs-material
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: development
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.pre-commit-config.yaml | 2 +-
Pipfile.lock | 135 +++++++++---------
.../management/commands/document_consumer.py | 6 +-
.../management/commands/document_exporter.py | 8 +-
src/documents/serialisers.py | 2 +-
src/paperless/__init__.py | 4 +-
src/paperless_tesseract/__init__.py | 2 +-
7 files changed, 77 insertions(+), 82 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cd8e47ae9..df90b225c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -48,7 +48,7 @@ repos:
exclude: "(^Pipfile\\.lock$)"
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: 'v0.7.3'
+ rev: 'v0.8.0'
hooks:
- id: ruff
- id: ruff-format
diff --git a/Pipfile.lock b/Pipfile.lock
index 765748dd1..0870e9d6b 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "a194c6834fba6a14712ba36eb0b896f18d7ef4393523e5d55ccb103104e99ddb"
+ "sha256": "e4cb2328c49829f56793ef25780dcc73ea8e4838e6e9bc25d1b6feb74eb3befe"
},
"pipfile-spec": 6,
"requires": {},
@@ -3242,11 +3242,11 @@
},
"httpcore": {
"hashes": [
- "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f",
- "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"
+ "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c",
+ "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"
],
"markers": "python_version >= '3.8'",
- "version": "==1.0.6"
+ "version": "==1.0.7"
},
"httpx": {
"extras": [
@@ -3311,7 +3311,6 @@
"sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369",
"sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"
],
- "index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==3.1.4"
},
@@ -3424,12 +3423,12 @@
},
"mkdocs-material": {
"hashes": [
- "sha256:47015f9c167d58a5ff5e682da37441fc4d66a1c79334bfc08d774763cacf69ca",
- "sha256:f3a6c968e524166b3f3ed1fb97d3ed3e0091183b0545cedf7156a2a6804c56c0"
+ "sha256:98f0a2039c62e551a68aad0791a8d41324ff90c03a6e6cea381a384b84908b83",
+ "sha256:ae2043f4238e572f9a40e0b577f50400d6fc31e2fef8ea141800aebf3bd273d7"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==9.5.44"
+ "version": "==9.5.46"
},
"mkdocs-material-extensions": {
"hashes": [
@@ -3723,12 +3722,12 @@
},
"pytest-httpx": {
"hashes": [
- "sha256:4af9ab0dae5e9c14cb1e27d18af3db1f627b2cf3b11c02b34ddf26aff6b0a24c",
- "sha256:bdd1b00a846cfe857194e4d3ba72dc08ba0d163154a4404269c9b971f357c05d"
+ "sha256:3ca4b0975c0f93b985f17df19e76430c1086b5b0cce32b1af082d8901296a735",
+ "sha256:42cf0a66f7b71b9111db2897e8b38a903abd33a27b11c48aff4a3c7650313af2"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==0.33.0"
+ "version": "==0.34.0"
},
"pytest-mock": {
"hashes": [
@@ -3741,12 +3740,12 @@
},
"pytest-rerunfailures": {
"hashes": [
- "sha256:4197bdd2eaeffdbf50b5ea6e7236f47ff0e44d1def8dae08e409f536d84e7b32",
- "sha256:4a400bcbcd3c7a4ad151ab8afac123d90eca3abe27f98725dc4d9702887d2e92"
+ "sha256:2d9ac7baf59f4c13ac730b47f6fa80e755d1ba0581da45ce30b72fb3542b4474",
+ "sha256:dd150c4795c229ef44320adc9a0c0532c51b78bb7a6843a8c53556b9a611df1a"
],
"index": "pypi",
- "markers": "python_version >= '3.8'",
- "version": "==14.0"
+ "markers": "python_version >= '3.9'",
+ "version": "==15.0"
},
"pytest-sugar": {
"hashes": [
@@ -3770,8 +3769,7 @@
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
],
- "index": "pypi",
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.9.0.post0"
},
"pywavelets": {
@@ -3993,28 +3991,28 @@
},
"ruff": {
"hashes": [
- "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2",
- "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c",
- "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344",
- "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9",
- "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2",
- "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299",
- "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc",
- "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088",
- "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16",
- "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5",
- "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d",
- "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29",
- "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e",
- "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67",
- "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2",
- "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5",
- "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313",
- "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"
+ "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c",
+ "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b",
+ "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df",
+ "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9",
+ "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f",
+ "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468",
+ "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426",
+ "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a",
+ "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd",
+ "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70",
+ "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2",
+ "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c",
+ "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44",
+ "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6",
+ "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362",
+ "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99",
+ "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3",
+ "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
- "version": "==0.7.3"
+ "version": "==0.8.0"
},
"scipy": {
"hashes": [
@@ -4076,7 +4074,7 @@
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"sniffio": {
@@ -4148,40 +4146,39 @@
},
"watchdog": {
"hashes": [
- "sha256:0f9332243355643d567697c3e3fa07330a1d1abf981611654a1f2bf2175612b7",
- "sha256:1021223c08ba8d2d38d71ec1704496471ffd7be42cfb26b87cd5059323a389a1",
- "sha256:108f42a7f0345042a854d4d0ad0834b741d421330d5f575b81cb27b883500176",
- "sha256:1e9679245e3ea6498494b3028b90c7b25dbb2abe65c7d07423ecfc2d6218ff7c",
- "sha256:223160bb359281bb8e31c8f1068bf71a6b16a8ad3d9524ca6f523ac666bb6a1e",
- "sha256:26dd201857d702bdf9d78c273cafcab5871dd29343748524695cecffa44a8d97",
- "sha256:294b7a598974b8e2c6123d19ef15de9abcd282b0fbbdbc4d23dfa812959a9e05",
- "sha256:349c9488e1d85d0a58e8cb14222d2c51cbc801ce11ac3936ab4c3af986536926",
- "sha256:49f4d36cb315c25ea0d946e018c01bb028048023b9e103d3d3943f58e109dd45",
- "sha256:53a3f10b62c2d569e260f96e8d966463dec1a50fa4f1b22aec69e3f91025060e",
- "sha256:53adf73dcdc0ef04f7735066b4a57a4cd3e49ef135daae41d77395f0b5b692cb",
- "sha256:560135542c91eaa74247a2e8430cf83c4342b29e8ad4f520ae14f0c8a19cfb5b",
- "sha256:720ef9d3a4f9ca575a780af283c8fd3a0674b307651c1976714745090da5a9e8",
- "sha256:752fb40efc7cc8d88ebc332b8f4bcbe2b5cc7e881bccfeb8e25054c00c994ee3",
- "sha256:78864cc8f23dbee55be34cc1494632a7ba30263951b5b2e8fc8286b95845f82c",
- "sha256:85527b882f3facda0579bce9d743ff7f10c3e1e0db0a0d0e28170a7d0e5ce2ea",
- "sha256:90a67d7857adb1d985aca232cc9905dd5bc4803ed85cfcdcfcf707e52049eda7",
- "sha256:91b522adc25614cdeaf91f7897800b82c13b4b8ac68a42ca959f992f6990c490",
- "sha256:9413384f26b5d050b6978e6fcd0c1e7f0539be7a4f1a885061473c5deaa57221",
- "sha256:94d11b07c64f63f49876e0ab8042ae034674c8653bfcdaa8c4b32e71cfff87e8",
- "sha256:950f531ec6e03696a2414b6308f5c6ff9dab7821a768c9d5788b1314e9a46ca7",
- "sha256:a2e8f3f955d68471fa37b0e3add18500790d129cc7efe89971b8a4cc6fdeb0b2",
- "sha256:ae6deb336cba5d71476caa029ceb6e88047fc1dc74b62b7c4012639c0b563906",
- "sha256:b8ca4d854adcf480bdfd80f46fdd6fb49f91dd020ae11c89b3a79e19454ec627",
- "sha256:c66f80ee5b602a9c7ab66e3c9f36026590a0902db3aea414d59a2f55188c1f49",
- "sha256:d52db5beb5e476e6853da2e2d24dbbbed6797b449c8bf7ea118a4ee0d2c9040e",
- "sha256:dd021efa85970bd4824acacbb922066159d0f9e546389a4743d56919b6758b91",
- "sha256:e25adddab85f674acac303cf1f5835951345a56c5f7f582987d266679979c75b",
- "sha256:f00b4cf737f568be9665563347a910f8bdc76f88c2970121c86243c8cfdf90e9",
- "sha256:f01f4a3565a387080dc49bdd1fefe4ecc77f894991b88ef927edbfa45eb10818"
+ "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a",
+ "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2",
+ "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f",
+ "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c",
+ "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c",
+ "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c",
+ "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0",
+ "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13",
+ "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134",
+ "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa",
+ "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e",
+ "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379",
+ "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a",
+ "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11",
+ "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282",
+ "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b",
+ "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f",
+ "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c",
+ "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112",
+ "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948",
+ "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881",
+ "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860",
+ "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3",
+ "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680",
+ "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26",
+ "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26",
+ "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e",
+ "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8",
+ "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c",
+ "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"
],
- "index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==5.0.3"
+ "version": "==6.0.0"
},
"zope-interface": {
"hashes": [
diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py
index 1eb2f6541..6b2706733 100644
--- a/src/documents/management/commands/document_consumer.py
+++ b/src/documents/management/commands/document_consumer.py
@@ -317,10 +317,8 @@ class Command(BaseCommand):
# Check the files against the timeout
still_waiting = {}
- for filepath in notified_files:
- # Time of the last inotify event for this file
- last_event_time = notified_files[filepath]
-
+ # last_event_time is time of the last inotify event for this file
+ for filepath, last_event_time in notified_files.items():
# Current time - last time over the configured timeout
waited_long_enough = (
monotonic() - last_event_time
diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py
index 84275507d..2f85ad8f8 100644
--- a/src/documents/management/commands/document_exporter.py
+++ b/src/documents/management/commands/document_exporter.py
@@ -294,9 +294,9 @@ class Command(CryptMixin, BaseCommand):
manifest_dict = {}
# Build an overall manifest
- for key in manifest_key_to_object_query:
+ for key, object_query in manifest_key_to_object_query.items():
manifest_dict[key] = json.loads(
- serializers.serialize("json", manifest_key_to_object_query[key]),
+ serializers.serialize("json", object_query),
)
self.encrypt_secret_fields(manifest_dict)
@@ -370,8 +370,8 @@ class Command(CryptMixin, BaseCommand):
# 4.1 write primary manifest to target folder
manifest = []
- for key in manifest_dict:
- manifest.extend(manifest_dict[key])
+ for key, item in manifest_dict.items():
+ manifest.extend(item)
manifest_path = (self.target / "manifest.json").resolve()
self.check_and_write_json(
manifest,
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 8c7973f96..74b705af3 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -160,7 +160,7 @@ class SetPermissionsMixin:
},
}
if set_permissions is not None:
- for action in permissions_dict:
+ for action, _ in permissions_dict.items():
if action in set_permissions:
users = set_permissions[action]["users"]
permissions_dict[action]["users"] = self._validate_user_ids(users)
diff --git a/src/paperless/__init__.py b/src/paperless/__init__.py
index 54ff3cb79..ac8326935 100644
--- a/src/paperless/__init__.py
+++ b/src/paperless/__init__.py
@@ -5,9 +5,9 @@ from paperless.checks import paths_check
from paperless.checks import settings_values_check
__all__ = [
- "celery_app",
+ "audit_log_check",
"binaries_check",
+ "celery_app",
"paths_check",
"settings_values_check",
- "audit_log_check",
]
diff --git a/src/paperless_tesseract/__init__.py b/src/paperless_tesseract/__init__.py
index 9976fb403..cc0b886aa 100644
--- a/src/paperless_tesseract/__init__.py
+++ b/src/paperless_tesseract/__init__.py
@@ -2,4 +2,4 @@
from paperless_tesseract.checks import check_default_language_available
from paperless_tesseract.checks import get_tesseract_langs
-__all__ = ["get_tesseract_langs", "check_default_language_available"]
+__all__ = ["check_default_language_available", "get_tesseract_langs"]
From 0fc1860d4ccc77366a05b89e69a975bc48847cde Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Sun, 1 Dec 2024 20:15:38 -0800
Subject: [PATCH 21/23] Enhancement: use stable unique IDs for custom field
select options (#8299)
---
.../custom-field-display.component.spec.ts | 10 +-
.../custom-field-display.component.ts | 4 +-
...ustom-fields-query-dropdown.component.html | 4 +
...om-fields-query-dropdown.component.spec.ts | 19 ++-
.../custom-fields-query-dropdown.component.ts | 4 +-
.../custom-field-edit-dialog.component.html | 5 +-
...custom-field-edit-dialog.component.spec.ts | 16 +-
.../custom-field-edit-dialog.component.ts | 19 ++-
.../input/select/select.component.spec.ts | 8 -
.../common/input/select/select.component.ts | 5 -
.../document-detail.component.html | 3 +-
src-ui/src/app/data/custom-field.ts | 2 +-
src/documents/filters.py | 26 +--
.../management/commands/document_importer.py | 4 +-
..._alter_customfieldinstance_value_select.py | 79 +++++++++
src/documents/models.py | 8 +-
src/documents/serialisers.py | 29 +++-
src/documents/signals/handlers.py | 43 +++--
src/documents/templating/filepath.py | 6 +-
src/documents/tests/test_api_custom_fields.py | 154 +++++++++++++++++-
src/documents/tests/test_api_documents.py | 7 +-
.../tests/test_api_filter_by_custom_fields.py | 26 +--
src/documents/tests/test_file_handling.py | 27 ++-
.../test_migration_custom_field_selects.py | 87 ++++++++++
24 files changed, 494 insertions(+), 101 deletions(-)
create mode 100644 src/documents/migrations/1059_alter_customfieldinstance_value_select.py
create mode 100644 src/documents/tests/test_migration_custom_field_selects.py
diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts
index ea60034e4..824e1e05b 100644
--- a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts
+++ b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.spec.ts
@@ -17,7 +17,11 @@ const customFields: CustomField[] = [
name: 'Field 4',
data_type: CustomFieldDataType.Select,
extra_data: {
- select_options: ['Option 1', 'Option 2', 'Option 3'],
+ select_options: [
+ { label: 'Option 1', id: 'abc-123' },
+ { label: 'Option 2', id: 'def-456' },
+ { label: 'Option 3', id: 'ghi-789' },
+ ],
},
},
{
@@ -131,6 +135,8 @@ describe('CustomFieldDisplayComponent', () => {
})
it('should show select value', () => {
- expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3')
+ expect(component.getSelectValue(customFields[3], 'ghi-789')).toEqual(
+ 'Option 3'
+ )
})
})
diff --git a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts
index f541f0e47..1ab831f46 100644
--- a/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts
+++ b/src-ui/src/app/components/common/custom-field-display/custom-field-display.component.ts
@@ -117,8 +117,8 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
return this.docLinkDocuments?.find((d) => d.id === docId)?.title
}
- public getSelectValue(field: CustomField, index: number): string {
- return field.extra_data.select_options[index]
+ public getSelectValue(field: CustomField, id: string): string {
+ return field.extra_data.select_options?.find((o) => o.id === id)?.label
}
ngOnDestroy(): void {
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
index 9cc095d7d..768a79af5 100644
--- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
+++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html
@@ -44,6 +44,8 @@
{
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.Select,
- extra_data: { select_options: ['Option 1', 'Option 2'] },
+ extra_data: {
+ select_options: [
+ { label: 'Option 1', id: 'abc-123' },
+ { label: 'Option 2', id: 'def-456' },
+ ],
+ },
}
component.customFields = [field]
const options = component.getSelectOptionsForField(1)
- expect(options).toEqual(['Option 1', 'Option 2'])
+ expect(options).toEqual([
+ { label: 'Option 1', id: 'abc-123' },
+ { label: 'Option 2', id: 'def-456' },
+ ])
// Fallback to empty array if field is not found
const options2 = component.getSelectOptionsForField(2)
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
index b0d446dd0..2233fc5c4 100644
--- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
+++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
@@ -311,7 +311,9 @@ export class CustomFieldsQueryDropdownComponent implements OnDestroy {
}))
}
- getSelectOptionsForField(fieldID: number): string[] {
+ getSelectOptionsForField(
+ fieldID: number
+ ): Array<{ label: string; id: string }> {
const field = this.customFields.find((field) => field.id === fieldID)
if (field) {
return field.extra_data['select_options']
diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html
index d48c0788b..b4216e41c 100644
--- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html
+++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html
@@ -21,8 +21,9 @@
@for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) {
-
-
+
+
+
Delete
}
diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts
index 2de17577f..6ecf72b5d 100644
--- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts
+++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.spec.ts
@@ -80,7 +80,11 @@ describe('CustomFieldEditDialogComponent', () => {
name: 'Field 1',
data_type: CustomFieldDataType.Select,
extra_data: {
- select_options: ['Option 1', 'Option 2', 'Option 3'],
+ select_options: [
+ { label: 'Option 1', id: '123-xyz' },
+ { label: 'Option 2', id: '456-abc' },
+ { label: 'Option 3', id: '789-123' },
+ ],
},
}
fixture.detectChanges()
@@ -94,6 +98,10 @@ describe('CustomFieldEditDialogComponent', () => {
component.dialogMode = EditDialogMode.CREATE
fixture.detectChanges()
component.ngOnInit()
+ expect(
+ component.objectForm.get('extra_data').get('select_options').value.length
+ ).toBe(0)
+ component.addSelectOption()
expect(
component.objectForm.get('extra_data').get('select_options').value.length
).toBe(1)
@@ -101,14 +109,10 @@ describe('CustomFieldEditDialogComponent', () => {
expect(
component.objectForm.get('extra_data').get('select_options').value.length
).toBe(2)
- component.addSelectOption()
- expect(
- component.objectForm.get('extra_data').get('select_options').value.length
- ).toBe(3)
component.removeSelectOption(0)
expect(
component.objectForm.get('extra_data').get('select_options').value.length
- ).toBe(2)
+ ).toBe(1)
})
it('should focus on last select option input', () => {
diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts
index b27ec9fcd..e39e27edd 100644
--- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts
@@ -57,9 +57,16 @@ export class CustomFieldEditDialogComponent
}
if (this.object?.data_type === CustomFieldDataType.Select) {
this.selectOptions.clear()
- this.object.extra_data.select_options.forEach((option) =>
- this.selectOptions.push(new FormControl(option))
- )
+ this.object.extra_data.select_options
+ .filter((option) => option)
+ .forEach((option) =>
+ this.selectOptions.push(
+ new FormGroup({
+ label: new FormControl(option.label),
+ id: new FormControl(option.id),
+ })
+ )
+ )
}
}
@@ -89,7 +96,7 @@ export class CustomFieldEditDialogComponent
name: new FormControl(null),
data_type: new FormControl(null),
extra_data: new FormGroup({
- select_options: new FormArray([new FormControl(null)]),
+ select_options: new FormArray([]),
default_currency: new FormControl(null),
}),
})
@@ -104,7 +111,9 @@ export class CustomFieldEditDialogComponent
}
public addSelectOption() {
- this.selectOptions.push(new FormControl(''))
+ this.selectOptions.push(
+ new FormGroup({ label: new FormControl(null), id: new FormControl(null) })
+ )
}
public removeSelectOption(index: number) {
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 2c39035a2..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
@@ -132,12 +132,4 @@ describe('SelectComponent', () => {
const expectedTitle = `Filter documents with this ${component.title}`
expect(component.filterButtonTitle).toEqual(expectedTitle)
})
-
- it('should support setting items as a plain array', () => {
- component.itemsArray = ['foo', 'bar']
- expect(component.items).toEqual([
- { id: 0, name: 'foo' },
- { id: 1, name: 'bar' },
- ])
- })
})
diff --git a/src-ui/src/app/components/common/input/select/select.component.ts b/src-ui/src/app/components/common/input/select/select.component.ts
index d9976698e..19f6375ad 100644
--- a/src-ui/src/app/components/common/input/select/select.component.ts
+++ b/src-ui/src/app/components/common/input/select/select.component.ts
@@ -34,11 +34,6 @@ export class SelectComponent extends AbstractInputComponent {
if (items && this.value) this.checkForPrivateItems(this.value)
}
- @Input()
- set itemsArray(items: any[]) {
- this._items = items.map((item, index) => ({ id: index, name: item }))
- }
-
writeValue(newValue: any): void {
if (newValue && this._items) {
this.checkForPrivateItems(newValue)
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index 486277c21..86767b6e7 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -190,7 +190,8 @@
@case (CustomFieldDataType.Select) {
default_currency?: string
}
document_count?: number
diff --git a/src/documents/filters.py b/src/documents/filters.py
index e8065c472..237973b6f 100644
--- a/src/documents/filters.py
+++ b/src/documents/filters.py
@@ -176,9 +176,9 @@ class CustomFieldsFilter(Filter):
if fields_with_matching_selects.count() > 0:
for field in fields_with_matching_selects:
options = field.extra_data.get("select_options", [])
- for index, option in enumerate(options):
- if option.lower().find(value.lower()) != -1:
- option_ids.extend([index])
+ for _, option in enumerate(options):
+ if option.get("label").lower().find(value.lower()) != -1:
+ option_ids.extend([option.get("id")])
return (
qs.filter(custom_fields__field__name__icontains=value)
| qs.filter(custom_fields__value_text__icontains=value)
@@ -195,19 +195,21 @@ class CustomFieldsFilter(Filter):
return qs
-class SelectField(serializers.IntegerField):
+class SelectField(serializers.CharField):
def __init__(self, custom_field: CustomField):
self._options = custom_field.extra_data["select_options"]
- super().__init__(min_value=0, max_value=len(self._options))
+ super().__init__(max_length=16)
def to_internal_value(self, data):
- if not isinstance(data, int):
- # If the supplied value is not an integer,
- # we will try to map it to an option index.
- try:
- data = self._options.index(data)
- except ValueError:
- pass
+ # If the supplied value is the option label instead of the ID
+ try:
+ data = next(
+ option.get("id")
+ for option in self._options
+ if option.get("label") == data
+ )
+ except StopIteration:
+ pass
return super().to_internal_value(data)
diff --git a/src/documents/management/commands/document_importer.py b/src/documents/management/commands/document_importer.py
index 22c626eba..f56159c81 100644
--- a/src/documents/management/commands/document_importer.py
+++ b/src/documents/management/commands/document_importer.py
@@ -34,7 +34,7 @@ from documents.settings import EXPORTER_ARCHIVE_NAME
from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME
from documents.settings import EXPORTER_FILE_NAME
from documents.settings import EXPORTER_THUMBNAIL_NAME
-from documents.signals.handlers import update_cf_instance_documents
+from documents.signals.handlers import check_paths_and_prune_custom_fields
from documents.signals.handlers import update_filename_and_move_files
from documents.utils import copy_file_with_basic_stats
from paperless import version
@@ -262,7 +262,7 @@ class Command(CryptMixin, BaseCommand):
),
disable_signal(
post_save,
- receiver=update_cf_instance_documents,
+ receiver=check_paths_and_prune_custom_fields,
sender=CustomField,
),
):
diff --git a/src/documents/migrations/1059_alter_customfieldinstance_value_select.py b/src/documents/migrations/1059_alter_customfieldinstance_value_select.py
new file mode 100644
index 000000000..00ab11f65
--- /dev/null
+++ b/src/documents/migrations/1059_alter_customfieldinstance_value_select.py
@@ -0,0 +1,79 @@
+# Generated by Django 5.1.1 on 2024-11-13 05:14
+
+from django.db import migrations
+from django.db import models
+from django.db import transaction
+from django.utils.crypto import get_random_string
+
+
+def migrate_customfield_selects(apps, schema_editor):
+ """
+ Migrate the custom field selects from a simple list of strings to a list of dictionaries with
+ label and id. Then update all instances of the custom field to use the new format.
+ """
+ CustomFieldInstance = apps.get_model("documents", "CustomFieldInstance")
+ CustomField = apps.get_model("documents", "CustomField")
+
+ with transaction.atomic():
+ for custom_field in CustomField.objects.filter(
+ data_type="select",
+ ): # CustomField.FieldDataType.SELECT
+ old_select_options = custom_field.extra_data["select_options"]
+ custom_field.extra_data["select_options"] = [
+ {"id": get_random_string(16), "label": value}
+ for value in old_select_options
+ ]
+ custom_field.save()
+
+ for instance in CustomFieldInstance.objects.filter(field=custom_field):
+ if instance.value_select:
+ instance.value_select = custom_field.extra_data["select_options"][
+ int(instance.value_select)
+ ]["id"]
+ instance.save()
+
+
+def reverse_migrate_customfield_selects(apps, schema_editor):
+ """
+ Reverse the migration of the custom field selects from a list of dictionaries with label and id
+ to a simple list of strings. Then update all instances of the custom field to use the old format,
+ which is just the index of the selected option.
+ """
+ CustomFieldInstance = apps.get_model("documents", "CustomFieldInstance")
+ CustomField = apps.get_model("documents", "CustomField")
+
+ with transaction.atomic():
+ for custom_field in CustomField.objects.all():
+ if custom_field.data_type == "select": # CustomField.FieldDataType.SELECT
+ old_select_options = custom_field.extra_data["select_options"]
+ custom_field.extra_data["select_options"] = [
+ option["label"]
+ for option in custom_field.extra_data["select_options"]
+ ]
+ custom_field.save()
+
+ for instance in CustomFieldInstance.objects.filter(field=custom_field):
+ instance.value_select = next(
+ index
+ for index, option in enumerate(old_select_options)
+ if option.get("id") == instance.value_select
+ )
+ instance.save()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "1058_workflowtrigger_schedule_date_custom_field_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="customfieldinstance",
+ name="value_select",
+ field=models.CharField(max_length=16, null=True),
+ ),
+ migrations.RunPython(
+ migrate_customfield_selects,
+ reverse_migrate_customfield_selects,
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 6ba63a7e4..2eb5d817c 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -947,7 +947,7 @@ class CustomFieldInstance(SoftDeleteModel):
value_document_ids = models.JSONField(null=True)
- value_select = models.PositiveSmallIntegerField(null=True)
+ value_select = models.CharField(null=True, max_length=16)
class Meta:
ordering = ("created",)
@@ -962,7 +962,11 @@ class CustomFieldInstance(SoftDeleteModel):
def __str__(self) -> str:
value = (
- self.field.extra_data["select_options"][self.value_select]
+ next(
+ option.get("label")
+ for option in self.field.extra_data["select_options"]
+ if option.get("id") == self.value_select
+ )
if (
self.field.data_type == CustomField.FieldDataType.SELECT
and self.value_select is not None
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 74b705af3..9ab9bf40e 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -533,20 +533,27 @@ class CustomFieldSerializer(serializers.ModelSerializer):
if (
"data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.SELECT
- and (
+ ) or (
+ self.instance
+ and self.instance.data_type == CustomField.FieldDataType.SELECT
+ ):
+ if (
"extra_data" not in attrs
or "select_options" not in attrs["extra_data"]
or not isinstance(attrs["extra_data"]["select_options"], list)
or len(attrs["extra_data"]["select_options"]) == 0
or not all(
- isinstance(option, str) and len(option) > 0
+ len(option.get("label", "")) > 0
for option in attrs["extra_data"]["select_options"]
)
- )
- ):
- raise serializers.ValidationError(
- {"error": "extra_data.select_options must be a valid list"},
- )
+ ):
+ raise serializers.ValidationError(
+ {"error": "extra_data.select_options must be a valid list"},
+ )
+ # labels are valid, generate ids if not present
+ for option in attrs["extra_data"]["select_options"]:
+ if option.get("id") is None:
+ option["id"] = get_random_string(length=16)
elif (
"data_type" in attrs
and attrs["data_type"] == CustomField.FieldDataType.MONETARY
@@ -646,10 +653,14 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
elif field.data_type == CustomField.FieldDataType.SELECT:
select_options = field.extra_data["select_options"]
try:
- select_options[data["value"]]
+ next(
+ option
+ for option in select_options
+ if option["id"] == data["value"]
+ )
except Exception:
raise serializers.ValidationError(
- f"Value must be index of an element in {select_options}",
+ f"Value must be an id of an element in {select_options}",
)
elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
doc_ids = data["value"]
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index c6d6c4090..853acdc15 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -368,21 +368,6 @@ class CannotMoveFilesException(Exception):
pass
-# should be disabled in /src/documents/management/commands/document_importer.py handle
-@receiver(models.signals.post_save, sender=CustomField)
-def update_cf_instance_documents(sender, instance: CustomField, **kwargs):
- """
- 'Select' custom field instances get their end-user value (e.g. in file names) from the select_options in extra_data,
- which is contained in the custom field itself. So when the field is changed, we (may) need to update the file names
- of all documents that have this custom field.
- """
- if (
- instance.data_type == CustomField.FieldDataType.SELECT
- ): # Only select fields, for now
- for cf_instance in instance.fields.all():
- update_filename_and_move_files(sender, cf_instance)
-
-
# should be disabled in /src/documents/management/commands/document_importer.py handle
@receiver(models.signals.post_save, sender=CustomFieldInstance)
@receiver(models.signals.m2m_changed, sender=Document.tags.through)
@@ -521,6 +506,34 @@ def update_filename_and_move_files(
)
+# should be disabled in /src/documents/management/commands/document_importer.py handle
+@receiver(models.signals.post_save, sender=CustomField)
+def check_paths_and_prune_custom_fields(sender, instance: CustomField, **kwargs):
+ """
+ When a custom field is updated:
+ 1. 'Select' custom field instances get their end-user value (e.g. in file names) from the select_options in extra_data,
+ which is contained in the custom field itself. So when the field is changed, we (may) need to update the file names
+ of all documents that have this custom field.
+ 2. If a 'Select' field option was removed, we need to nullify the custom field instances that have the option.
+ """
+ if (
+ instance.data_type == CustomField.FieldDataType.SELECT
+ ): # Only select fields, for now
+ for cf_instance in instance.fields.all():
+ options = instance.extra_data.get("select_options", [])
+ try:
+ next(
+ option["label"]
+ for option in options
+ if option["id"] == cf_instance.value
+ )
+ except StopIteration:
+ # The value of this custom field instance is not in the select options anymore
+ cf_instance.value_select = None
+ cf_instance.save()
+ update_filename_and_move_files(sender, cf_instance)
+
+
def set_log_entry(sender, document: Document, logging_group=None, **kwargs):
ct = ContentType.objects.get(model="document")
user = User.objects.get(username="consumer")
diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py
index 108ad0c81..cbe621d77 100644
--- a/src/documents/templating/filepath.py
+++ b/src/documents/templating/filepath.py
@@ -253,7 +253,11 @@ def get_custom_fields_context(
):
options = field_instance.field.extra_data["select_options"]
value = pathvalidate.sanitize_filename(
- options[int(field_instance.value)],
+ next(
+ option["label"]
+ for option in options
+ if option["id"] == field_instance.value
+ ),
replacement_text="-",
)
else:
diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py
index 02e856c27..11911f6ab 100644
--- a/src/documents/tests/test_api_custom_fields.py
+++ b/src/documents/tests/test_api_custom_fields.py
@@ -1,5 +1,6 @@
import json
from datetime import date
+from unittest.mock import ANY
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
@@ -61,7 +62,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
"data_type": "select",
"name": "Select Field",
"extra_data": {
- "select_options": ["Option 1", "Option 2"],
+ "select_options": [
+ {"label": "Option 1", "id": "abc-123"},
+ {"label": "Option 2", "id": "def-456"},
+ ],
},
},
),
@@ -73,7 +77,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
self.assertCountEqual(
data["extra_data"]["select_options"],
- ["Option 1", "Option 2"],
+ [
+ {"label": "Option 1", "id": "abc-123"},
+ {"label": "Option 2", "id": "def-456"},
+ ],
)
def test_create_custom_field_nonunique_name(self):
@@ -138,6 +145,133 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
+ def test_custom_field_select_unique_ids(self):
+ """
+ GIVEN:
+ - Nothing
+ - Existing custom field
+ WHEN:
+ - API request to create custom field with select options without id
+ THEN:
+ - Unique ids are generated for each option
+ """
+ resp = self.client.post(
+ self.ENDPOINT,
+ json.dumps(
+ {
+ "data_type": "select",
+ "name": "Select Field",
+ "extra_data": {
+ "select_options": [
+ {"label": "Option 1"},
+ {"label": "Option 2"},
+ ],
+ },
+ },
+ ),
+ content_type="application/json",
+ )
+ self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
+
+ data = resp.json()
+
+ self.assertCountEqual(
+ data["extra_data"]["select_options"],
+ [
+ {"label": "Option 1", "id": ANY},
+ {"label": "Option 2", "id": ANY},
+ ],
+ )
+
+ # Add a new option
+ resp = self.client.patch(
+ f"{self.ENDPOINT}{data['id']}/",
+ json.dumps(
+ {
+ "extra_data": {
+ "select_options": data["extra_data"]["select_options"]
+ + [{"label": "Option 3"}],
+ },
+ },
+ ),
+ content_type="application/json",
+ )
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+
+ data = resp.json()
+
+ self.assertCountEqual(
+ data["extra_data"]["select_options"],
+ [
+ {"label": "Option 1", "id": ANY},
+ {"label": "Option 2", "id": ANY},
+ {"label": "Option 3", "id": ANY},
+ ],
+ )
+
+ def test_custom_field_select_options_pruned(self):
+ """
+ GIVEN:
+ - Select custom field exists and document instance with one of the options
+ WHEN:
+ - API request to remove an option from the select field
+ THEN:
+ - The option is removed from the field
+ - The option is removed from the document instance
+ """
+ custom_field_select = CustomField.objects.create(
+ name="Select Field",
+ data_type=CustomField.FieldDataType.SELECT,
+ extra_data={
+ "select_options": [
+ {"label": "Option 1", "id": "abc-123"},
+ {"label": "Option 2", "id": "def-456"},
+ {"label": "Option 3", "id": "ghi-789"},
+ ],
+ },
+ )
+
+ doc = Document.objects.create(
+ title="WOW",
+ content="the content",
+ checksum="123",
+ mime_type="application/pdf",
+ )
+ CustomFieldInstance.objects.create(
+ document=doc,
+ field=custom_field_select,
+ value_text="abc-123",
+ )
+
+ resp = self.client.patch(
+ f"{self.ENDPOINT}{custom_field_select.id}/",
+ json.dumps(
+ {
+ "extra_data": {
+ "select_options": [
+ {"label": "Option 1", "id": "abc-123"},
+ {"label": "Option 3", "id": "ghi-789"},
+ ],
+ },
+ },
+ ),
+ content_type="application/json",
+ )
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+
+ data = resp.json()
+
+ self.assertCountEqual(
+ data["extra_data"]["select_options"],
+ [
+ {"label": "Option 1", "id": "abc-123"},
+ {"label": "Option 3", "id": "ghi-789"},
+ ],
+ )
+
+ doc.refresh_from_db()
+ self.assertEqual(doc.custom_fields.first().value, None)
+
def test_create_custom_field_monetary_validation(self):
"""
GIVEN:
@@ -261,7 +395,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
name="Test Custom Field Select",
data_type=CustomField.FieldDataType.SELECT,
extra_data={
- "select_options": ["Option 1", "Option 2"],
+ "select_options": [
+ {"label": "Option 1", "id": "abc-123"},
+ {"label": "Option 2", "id": "def-456"},
+ ],
},
)
@@ -309,7 +446,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
},
{
"field": custom_field_select.id,
- "value": 0,
+ "value": "abc-123",
},
],
},
@@ -332,7 +469,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
{"field": custom_field_monetary.id, "value": "EUR11.10"},
{"field": custom_field_monetary2.id, "value": "11.1"},
{"field": custom_field_documentlink.id, "value": [doc2.id]},
- {"field": custom_field_select.id, "value": 0},
+ {"field": custom_field_select.id, "value": "abc-123"},
],
)
@@ -722,7 +859,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
name="Test Custom Field SELECT",
data_type=CustomField.FieldDataType.SELECT,
extra_data={
- "select_options": ["Option 1", "Option 2"],
+ "select_options": [
+ {"label": "Option 1", "id": "abc-123"},
+ {"label": "Option 2", "id": "def-456"},
+ ],
},
)
@@ -730,7 +870,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
f"/api/documents/{doc.id}/",
data={
"custom_fields": [
- {"field": custom_field_select.id, "value": 3},
+ {"field": custom_field_select.id, "value": "not an option"},
],
},
format="json",
diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py
index 08d86d24e..8307d6c4c 100644
--- a/src/documents/tests/test_api_documents.py
+++ b/src/documents/tests/test_api_documents.py
@@ -657,13 +657,16 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
name="Test Custom Field Select",
data_type=CustomField.FieldDataType.SELECT,
extra_data={
- "select_options": ["Option 1", "Choice 2"],
+ "select_options": [
+ {"label": "Option 1", "id": "abc123"},
+ {"label": "Choice 2", "id": "def456"},
+ ],
},
)
CustomFieldInstance.objects.create(
document=doc1,
field=custom_field_select,
- value_select=1,
+ value_select="def456",
)
r = self.client.get("/api/documents/?custom_fields__icontains=choice")
diff --git a/src/documents/tests/test_api_filter_by_custom_fields.py b/src/documents/tests/test_api_filter_by_custom_fields.py
index 4cba29152..c7e9092ed 100644
--- a/src/documents/tests/test_api_filter_by_custom_fields.py
+++ b/src/documents/tests/test_api_filter_by_custom_fields.py
@@ -46,7 +46,13 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
# Add some options to the select_field
select = self.custom_fields["select_field"]
- select.extra_data = {"select_options": ["A", "B", "C"]}
+ select.extra_data = {
+ "select_options": [
+ {"label": "A", "id": "abc-123"},
+ {"label": "B", "id": "def-456"},
+ {"label": "C", "id": "ghi-789"},
+ ],
+ }
select.save()
# Now we will create some test documents
@@ -122,9 +128,9 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
# CustomField.FieldDataType.SELECT
self._create_document(select_field=None)
- self._create_document(select_field=0)
- self._create_document(select_field=1)
- self._create_document(select_field=2)
+ self._create_document(select_field="abc-123")
+ self._create_document(select_field="def-456")
+ self._create_document(select_field="ghi-789")
def _create_document(self, **kwargs):
title = str(kwargs)
@@ -296,18 +302,18 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
)
def test_select(self):
- # For select fields, you can either specify the index
+ # For select fields, you can either specify the id of the option
# or the name of the option. They function exactly the same.
self._assert_query_match_predicate(
- ["select_field", "exact", 1],
+ ["select_field", "exact", "def-456"],
lambda document: "select_field" in document
- and document["select_field"] == 1,
+ and document["select_field"] == "def-456",
)
# This is the same as:
self._assert_query_match_predicate(
["select_field", "exact", "B"],
lambda document: "select_field" in document
- and document["select_field"] == 1,
+ and document["select_field"] == "def-456",
)
# ==========================================================#
@@ -522,9 +528,9 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
def test_invalid_value(self):
self._assert_validation_error(
- json.dumps(["select_field", "exact", "not an option"]),
+ json.dumps(["select_field", "exact", []]),
["custom_field_query", "2"],
- "integer",
+ "string",
)
def test_invalid_logical_operator(self):
diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py
index 476068a51..2ec388501 100644
--- a/src/documents/tests/test_file_handling.py
+++ b/src/documents/tests/test_file_handling.py
@@ -544,7 +544,11 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
name="test",
data_type=CustomField.FieldDataType.SELECT,
extra_data={
- "select_options": ["apple", "banana", "cherry"],
+ "select_options": [
+ {"label": "apple", "id": "abc123"},
+ {"label": "banana", "id": "def456"},
+ {"label": "cherry", "id": "ghi789"},
+ ],
},
)
doc = Document.objects.create(
@@ -555,14 +559,22 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
archive_checksum="B",
mime_type="application/pdf",
)
- CustomFieldInstance.objects.create(field=cf, document=doc, value_select=0)
+ CustomFieldInstance.objects.create(
+ field=cf,
+ document=doc,
+ value_select="abc123",
+ )
self.assertEqual(generate_filename(doc), "document_apple.pdf")
# handler should not have been called
self.assertEqual(m.call_count, 0)
cf.extra_data = {
- "select_options": ["aubergine", "banana", "cherry"],
+ "select_options": [
+ {"label": "aubergine", "id": "abc123"},
+ {"label": "banana", "id": "def456"},
+ {"label": "cherry", "id": "ghi789"},
+ ],
}
cf.save()
self.assertEqual(generate_filename(doc), "document_aubergine.pdf")
@@ -1373,13 +1385,18 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
cf2 = CustomField.objects.create(
name="Select Field",
data_type=CustomField.FieldDataType.SELECT,
- extra_data={"select_options": ["ChoiceOne", "ChoiceTwo"]},
+ extra_data={
+ "select_options": [
+ {"label": "ChoiceOne", "id": "abc=123"},
+ {"label": "ChoiceTwo", "id": "def-456"},
+ ],
+ },
)
cfi1 = CustomFieldInstance.objects.create(
document=doc_a,
field=cf2,
- value_select=0,
+ value_select="abc=123",
)
cfi = CustomFieldInstance.objects.create(
diff --git a/src/documents/tests/test_migration_custom_field_selects.py b/src/documents/tests/test_migration_custom_field_selects.py
new file mode 100644
index 000000000..b172bf7e8
--- /dev/null
+++ b/src/documents/tests/test_migration_custom_field_selects.py
@@ -0,0 +1,87 @@
+from unittest.mock import ANY
+
+from documents.tests.utils import TestMigrations
+
+
+class TestMigrateCustomFieldSelects(TestMigrations):
+ migrate_from = "1058_workflowtrigger_schedule_date_custom_field_and_more"
+ migrate_to = "1059_alter_customfieldinstance_value_select"
+
+ def setUpBeforeMigration(self, apps):
+ CustomField = apps.get_model("documents.CustomField")
+ self.old_format = CustomField.objects.create(
+ name="cf1",
+ data_type="select",
+ extra_data={"select_options": ["Option 1", "Option 2", "Option 3"]},
+ )
+ Document = apps.get_model("documents.Document")
+ doc = Document.objects.create(title="doc1")
+ CustomFieldInstance = apps.get_model("documents.CustomFieldInstance")
+ self.old_instance = CustomFieldInstance.objects.create(
+ field=self.old_format,
+ value_select=0,
+ document=doc,
+ )
+
+ def test_migrate_old_to_new_select_fields(self):
+ self.old_format.refresh_from_db()
+ self.old_instance.refresh_from_db()
+
+ self.assertEqual(
+ self.old_format.extra_data["select_options"],
+ [
+ {"label": "Option 1", "id": ANY},
+ {"label": "Option 2", "id": ANY},
+ {"label": "Option 3", "id": ANY},
+ ],
+ )
+
+ self.assertEqual(
+ self.old_instance.value_select,
+ self.old_format.extra_data["select_options"][0]["id"],
+ )
+
+
+class TestMigrationCustomFieldSelectsReverse(TestMigrations):
+ migrate_from = "1059_alter_customfieldinstance_value_select"
+ migrate_to = "1058_workflowtrigger_schedule_date_custom_field_and_more"
+
+ def setUpBeforeMigration(self, apps):
+ CustomField = apps.get_model("documents.CustomField")
+ self.new_format = CustomField.objects.create(
+ name="cf1",
+ data_type="select",
+ extra_data={
+ "select_options": [
+ {"label": "Option 1", "id": "id1"},
+ {"label": "Option 2", "id": "id2"},
+ {"label": "Option 3", "id": "id3"},
+ ],
+ },
+ )
+ Document = apps.get_model("documents.Document")
+ doc = Document.objects.create(title="doc1")
+ CustomFieldInstance = apps.get_model("documents.CustomFieldInstance")
+ self.new_instance = CustomFieldInstance.objects.create(
+ field=self.new_format,
+ value_select="id1",
+ document=doc,
+ )
+
+ def test_migrate_new_to_old_select_fields(self):
+ self.new_format.refresh_from_db()
+ self.new_instance.refresh_from_db()
+
+ self.assertEqual(
+ self.new_format.extra_data["select_options"],
+ [
+ "Option 1",
+ "Option 2",
+ "Option 3",
+ ],
+ )
+
+ self.assertEqual(
+ self.new_instance.value_select,
+ 0,
+ )
From e9254d4eefad109d1040711aeaa4d6b1c27fe7fb Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 2 Dec 2024 08:59:45 -0800
Subject: [PATCH 22/23] Chore(deps): Bump codecov/codecov-action in the actions
group (#8401)
Bumps the actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).
Updates `codecov/codecov-action` from 4 to 5
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)
---
updated-dependencies:
- dependency-name: codecov/codecov-action
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: actions
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/ci.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 029b97bd0..8bd31467b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -283,7 +283,7 @@ jobs:
merge-multiple: true
-
name: Upload frontend coverage to Codecov
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }}
@@ -299,7 +299,7 @@ jobs:
path: src/
-
name: Upload coverage to Codecov
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }}
From 81a5baa451d6705ed9de2e535e8152948c4cbe25 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 2 Dec 2024 23:29:56 +0000
Subject: [PATCH 23/23] Chore(deps-dev): Bump the development group with 4
updates (#8414)
* Chore(deps-dev): Bump the development group with 4 updates
Bumps the development group with 4 updates: [ruff](https://github.com/astral-sh/ruff), [pytest](https://github.com/pytest-dev/pytest), [pytest-httpx](https://github.com/Colin-b/pytest_httpx) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material).
Updates `ruff` from 0.8.0 to 0.8.1
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.8.0...0.8.1)
Updates `pytest` from 8.3.3 to 8.3.4
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4)
Updates `pytest-httpx` from 0.34.0 to 0.35.0
- [Release notes](https://github.com/Colin-b/pytest_httpx/releases)
- [Changelog](https://github.com/Colin-b/pytest_httpx/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/Colin-b/pytest_httpx/compare/v0.34.0...v0.35.0)
Updates `mkdocs-material` from 9.5.46 to 9.5.47
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.46...9.5.47)
---
updated-dependencies:
- dependency-name: ruff
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: development
- dependency-name: pytest
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: development
- dependency-name: pytest-httpx
dependency-type: direct:development
update-type: version-update:semver-minor
dependency-group: development
- dependency-name: mkdocs-material
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: development
...
Signed-off-by: dependabot[bot]
* Update .pre-commit-config.yaml
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
---
.pre-commit-config.yaml | 2 +-
Pipfile.lock | 64 ++++++++++++++++++++---------------------
2 files changed, 33 insertions(+), 33 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index df90b225c..feaf9c837 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -48,7 +48,7 @@ repos:
exclude: "(^Pipfile\\.lock$)"
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: 'v0.8.0'
+ rev: 'v0.8.1'
hooks:
- id: ruff
- id: ruff-format
diff --git a/Pipfile.lock b/Pipfile.lock
index 0870e9d6b..282e2f94b 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "e4cb2328c49829f56793ef25780dcc73ea8e4838e6e9bc25d1b6feb74eb3befe"
+ "sha256": "a194c6834fba6a14712ba36eb0b896f18d7ef4393523e5d55ccb103104e99ddb"
},
"pipfile-spec": 6,
"requires": {},
@@ -3253,11 +3253,11 @@
"http2"
],
"hashes": [
- "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0",
- "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"
+ "sha256:0858d3bab51ba7e386637f22a61d8ccddaeec5f3fe4209da3a6168dbb91573e0",
+ "sha256:dc0b419a0cfeb6e8b34e85167c0da2671206f5095f1baa9663d23bcfd6b535fc"
],
"markers": "python_version >= '3.8'",
- "version": "==0.27.2"
+ "version": "==0.28.0"
},
"hyperlink": {
"hashes": [
@@ -3423,12 +3423,12 @@
},
"mkdocs-material": {
"hashes": [
- "sha256:98f0a2039c62e551a68aad0791a8d41324ff90c03a6e6cea381a384b84908b83",
- "sha256:ae2043f4238e572f9a40e0b577f50400d6fc31e2fef8ea141800aebf3bd273d7"
+ "sha256:53fb9c9624e7865da6ec807d116cd7be24b3cb36ab31b1d1d1a9af58c56009a2",
+ "sha256:fc3b7a8e00ad896660bd3a5cc12ca0cb28bdc2bcbe2a946b5714c23ac91b0ede"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==9.5.46"
+ "version": "==9.5.47"
},
"mkdocs-material-extensions": {
"hashes": [
@@ -3686,12 +3686,12 @@
},
"pytest": {
"hashes": [
- "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181",
- "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"
+ "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6",
+ "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==8.3.3"
+ "version": "==8.3.4"
},
"pytest-cov": {
"hashes": [
@@ -3722,12 +3722,12 @@
},
"pytest-httpx": {
"hashes": [
- "sha256:3ca4b0975c0f93b985f17df19e76430c1086b5b0cce32b1af082d8901296a735",
- "sha256:42cf0a66f7b71b9111db2897e8b38a903abd33a27b11c48aff4a3c7650313af2"
+ "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f",
+ "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
- "version": "==0.34.0"
+ "version": "==0.35.0"
},
"pytest-mock": {
"hashes": [
@@ -3991,28 +3991,28 @@
},
"ruff": {
"hashes": [
- "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c",
- "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b",
- "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df",
- "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9",
- "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f",
- "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468",
- "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426",
- "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a",
- "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd",
- "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70",
- "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2",
- "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c",
- "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44",
- "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6",
- "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362",
- "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99",
- "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3",
- "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea"
+ "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871",
+ "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1",
+ "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d",
+ "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6",
+ "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5",
+ "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f",
+ "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1",
+ "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737",
+ "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790",
+ "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9",
+ "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540",
+ "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26",
+ "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c",
+ "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa",
+ "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087",
+ "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209",
+ "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5",
+ "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
- "version": "==0.8.0"
+ "version": "==0.8.1"
},
"scipy": {
"hashes": [
| |