|
- {{ document.title }} |
+
+ {{ document.title }}
+
+
+
+ |
{{ getDaysRemaining(document) }} days |
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 9a9afadb8..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(
@@ -721,6 +730,7 @@ export class DocumentDetailComponent
save(close: boolean = false) {
this.networkActive = true
+ ;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
this.documentsService
.update(this.document)
.pipe(first())
@@ -1163,6 +1173,7 @@ export class DocumentDetailComponent
splitDocument() {
let modal = this.modalService.open(SplitConfirmDialogComponent, {
backdrop: 'static',
+ size: 'lg',
})
modal.componentInstance.title = $localize`Split confirm`
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
@@ -1201,6 +1212,7 @@ export class DocumentDetailComponent
rotateDocument() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
+ size: 'lg',
})
modal.componentInstance.title = $localize`Rotate confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
@@ -1275,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()}`
+ },
+ })
+ }
}
diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
index f823fe2bb..6892cc823 100644
--- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
+++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
@@ -782,11 +782,11 @@ export class BulkEditorComponent
rotateSelected() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
+ size: 'lg',
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
- rotateDialog.message = $localize`This will alter the original copy.`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]
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/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts
index 823fb6e6c..36ea05ed6 100644
--- a/src-ui/src/app/components/document-list/document-list.component.spec.ts
+++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts
@@ -72,6 +72,7 @@ import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { PermissionsService } from 'src/app/services/permissions.service'
import { NgSelectModule } from '@ng-select/ng-select'
+import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
const docs: Document[] = [
{
@@ -137,6 +138,7 @@ describe('DocumentListComponent', () => {
UsernamePipe,
SafeHtmlPipe,
IsNumberPipe,
+ PreviewPopupComponent,
],
imports: [
RouterTestingModule.withRoutes(routes),
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/context_processors.py b/src/documents/context_processors.py
index a9200ac11..9a012bc3a 100644
--- a/src/documents/context_processors.py
+++ b/src/documents/context_processors.py
@@ -14,7 +14,7 @@ def settings(request):
app_logo = (
django_settings.APP_LOGO
if general_config.app_logo is None or len(general_config.app_logo) == 0
- else general_config.app_logo
+ else django_settings.BASE_URL + general_config.app_logo.lstrip("/")
)
return {
diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py
index ba0e1139f..075bbfd6a 100644
--- a/src/documents/tests/test_api_bulk_edit.py
+++ b/src/documents/tests/test_api_bulk_edit.py
@@ -1,7 +1,9 @@
import json
from unittest import mock
+from auditlog.models import LogEntry
from django.contrib.auth.models import User
+from django.test import override_settings
from guardian.shortcuts import assign_perm
from rest_framework import status
from rest_framework.test import APITestCase
@@ -51,8 +53,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.doc3.tags.add(self.t2)
self.doc4.tags.add(self.t1, self.t2)
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
- self.cf1 = CustomField.objects.create(name="cf1", data_type="text")
- self.cf2 = CustomField.objects.create(name="cf2", data_type="text")
+ self.cf1 = CustomField.objects.create(name="cf1", data_type="string")
+ self.cf2 = CustomField.objects.create(name="cf2", data_type="string")
+
+ def setup_mock(self, m, method_name, return_value="OK"):
+ m.return_value = return_value
+ m.__name__ = method_name
@mock.patch("documents.bulk_edit.bulk_update_documents.delay")
def test_api_set_correspondent(self, bulk_update_task_mock):
@@ -178,7 +184,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
def test_api_modify_tags(self, m):
- m.return_value = "OK"
+ self.setup_mock(m, "modify_tags")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -211,7 +217,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
- API returns HTTP 400
- modify_tags is not called
"""
- m.return_value = "OK"
+ self.setup_mock(m, "modify_tags")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -230,7 +236,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
def test_api_modify_custom_fields(self, m):
- m.return_value = "OK"
+ self.setup_mock(m, "modify_custom_fields")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -263,8 +269,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
- API returns HTTP 400
- modify_custom_fields is not called
"""
- m.return_value = "OK"
-
+ self.setup_mock(m, "modify_custom_fields")
# Missing add_custom_fields
response = self.client.post(
"/api/documents/bulk_edit/",
@@ -359,7 +364,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.delete")
def test_api_delete(self, m):
- m.return_value = "OK"
+ self.setup_mock(m, "delete")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -383,8 +388,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- set_storage_path is called with correct document IDs and storage_path ID
"""
- m.return_value = "OK"
-
+ self.setup_mock(m, "set_storage_path")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -414,8 +418,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- set_storage_path is called with correct document IDs and None storage_path
"""
- m.return_value = "OK"
-
+ self.setup_mock(m, "set_storage_path")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -728,7 +731,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions(self, m):
- m.return_value = "OK"
+ self.setup_mock(m, "set_permissions")
user1 = User.objects.create(username="user1")
user2 = User.objects.create(username="user2")
permissions = {
@@ -763,7 +766,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
def test_set_permissions_merge(self, m):
- m.return_value = "OK"
+ self.setup_mock(m, "set_permissions")
user1 = User.objects.create(username="user1")
user2 = User.objects.create(username="user2")
permissions = {
@@ -823,7 +826,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- User is not able to change permissions
"""
- m.return_value = "OK"
+ self.setup_mock(m, "set_permissions")
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
user1 = User.objects.create(username="user1")
@@ -875,7 +878,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- set_storage_path only called if user can edit all docs
"""
- m.return_value = "OK"
+ self.setup_mock(m, "set_storage_path")
self.doc1.owner = User.objects.get(username="temp_admin")
self.doc1.save()
user1 = User.objects.create(username="user1")
@@ -919,8 +922,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.rotate")
def test_rotate(self, m):
- m.return_value = "OK"
-
+ self.setup_mock(m, "rotate")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -974,8 +976,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.merge")
def test_merge(self, m):
- m.return_value = "OK"
-
+ self.setup_mock(m, "merge")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -1003,8 +1004,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
user1 = User.objects.create(username="user1")
self.client.force_authenticate(user=user1)
- m.return_value = "OK"
-
+ self.setup_mock(m, "merge")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -1053,8 +1053,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
THEN:
- The API fails with a correct error code
"""
- m.return_value = "OK"
-
+ self.setup_mock(m, "merge")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -1074,8 +1073,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.split")
def test_split(self, m):
- m.return_value = "OK"
-
+ self.setup_mock(m, "split")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -1165,8 +1163,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
def test_delete_pages(self, m):
- m.return_value = "OK"
-
+ self.setup_mock(m, "delete_pages")
response = self.client.post(
"/api/documents/bulk_edit/",
json.dumps(
@@ -1254,3 +1251,87 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(b"pages must be a list of integers", response.content)
+
+ @override_settings(AUDIT_LOG_ENABLED=True)
+ def test_bulk_edit_audit_log_enabled_simple_field(self):
+ """
+ GIVEN:
+ - Audit log is enabled
+ WHEN:
+ - API to bulk edit documents is called
+ THEN:
+ - Audit log is created
+ """
+ LogEntry.objects.all().delete()
+ response = self.client.post(
+ "/api/documents/bulk_edit/",
+ json.dumps(
+ {
+ "documents": [self.doc1.id],
+ "method": "set_correspondent",
+ "parameters": {"correspondent": self.c2.id},
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
+
+ @override_settings(AUDIT_LOG_ENABLED=True)
+ def test_bulk_edit_audit_log_enabled_tags(self):
+ """
+ GIVEN:
+ - Audit log is enabled
+ WHEN:
+ - API to bulk edit tags is called
+ THEN:
+ - Audit log is created
+ """
+ LogEntry.objects.all().delete()
+ response = self.client.post(
+ "/api/documents/bulk_edit/",
+ json.dumps(
+ {
+ "documents": [self.doc1.id],
+ "method": "modify_tags",
+ "parameters": {
+ "add_tags": [self.t1.id],
+ "remove_tags": [self.t2.id],
+ },
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1)
+
+ @override_settings(AUDIT_LOG_ENABLED=True)
+ def test_bulk_edit_audit_log_enabled_custom_fields(self):
+ """
+ GIVEN:
+ - Audit log is enabled
+ WHEN:
+ - API to bulk edit custom fields is called
+ THEN:
+ - Audit log is created
+ """
+ LogEntry.objects.all().delete()
+ response = self.client.post(
+ "/api/documents/bulk_edit/",
+ json.dumps(
+ {
+ "documents": [self.doc1.id],
+ "method": "modify_custom_fields",
+ "parameters": {
+ "add_custom_fields": [self.cf1.id],
+ "remove_custom_fields": [],
+ },
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2)
diff --git a/src/documents/tests/test_views.py b/src/documents/tests/test_views.py
index ca1db5782..9f52a6aa4 100644
--- a/src/documents/tests/test_views.py
+++ b/src/documents/tests/test_views.py
@@ -6,12 +6,14 @@ from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.test import TestCase
+from django.test import override_settings
from django.utils import timezone
from rest_framework import status
from documents.models import Document
from documents.models import ShareLink
from documents.tests.utils import DirectoriesMixin
+from paperless.models import ApplicationConfiguration
class TestViews(DirectoriesMixin, TestCase):
@@ -67,6 +69,26 @@ class TestViews(DirectoriesMixin, TestCase):
f"frontend/{language_actual}/main.js",
)
+ @override_settings(BASE_URL="/paperless/")
+ def test_index_app_logo_with_base_url(self):
+ """
+ GIVEN:
+ - Existing config with app_logo specified
+ WHEN:
+ - Index page is loaded
+ THEN:
+ - app_logo is prefixed with BASE_URL
+ """
+ config = ApplicationConfiguration.objects.first()
+ config.app_logo = "/logo/example.jpg"
+ config.save()
+ self.client.force_login(self.user)
+ response = self.client.get("/")
+ self.assertEqual(
+ response.context["APP_LOGO"],
+ f"/paperless{config.app_logo}",
+ )
+
def test_share_link_views(self):
"""
GIVEN:
diff --git a/src/documents/views.py b/src/documents/views.py
index 35fa8eafc..722ae7440 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -26,11 +26,13 @@ from django.db.models import Case
from django.db.models import Count
from django.db.models import IntegerField
from django.db.models import Max
+from django.db.models import Model
from django.db.models import Q
from django.db.models import Sum
from django.db.models import When
from django.db.models.functions import Length
from django.db.models.functions import Lower
+from django.db.models.manager import Manager
from django.http import Http404
from django.http import HttpResponse
from django.http import HttpResponseBadRequest
@@ -426,7 +428,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",
@@ -961,6 +963,22 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin):
class BulkEditView(PassUserMixin):
+ MODIFIED_FIELD_BY_METHOD = {
+ "set_correspondent": "correspondent",
+ "set_document_type": "document_type",
+ "set_storage_path": "storage_path",
+ "add_tag": "tags",
+ "remove_tag": "tags",
+ "modify_tags": "tags",
+ "modify_custom_fields": "custom_fields",
+ "set_permissions": None,
+ "delete": "deleted_at",
+ "rotate": "checksum",
+ "delete_pages": "checksum",
+ "split": None,
+ "merge": None,
+ }
+
permission_classes = (IsAuthenticated,)
serializer_class = BulkEditSerializer
parser_classes = (parsers.JSONParser,)
@@ -1013,8 +1031,53 @@ class BulkEditView(PassUserMixin):
return HttpResponseForbidden("Insufficient permissions")
try:
+ modified_field = self.MODIFIED_FIELD_BY_METHOD[method.__name__]
+ if settings.AUDIT_LOG_ENABLED and modified_field:
+ old_documents = {
+ obj["pk"]: obj
+ for obj in Document.objects.filter(pk__in=documents).values(
+ "pk",
+ "correspondent",
+ "document_type",
+ "storage_path",
+ "tags",
+ "custom_fields",
+ "deleted_at",
+ "checksum",
+ )
+ }
+
# TODO: parameter validation
result = method(documents, **parameters)
+
+ if settings.AUDIT_LOG_ENABLED and modified_field:
+ new_documents = Document.objects.filter(pk__in=documents)
+ for doc in new_documents:
+ old_value = old_documents[doc.pk][modified_field]
+ new_value = getattr(doc, modified_field)
+
+ if isinstance(new_value, Model):
+ # correspondent, document type, etc.
+ new_value = new_value.pk
+ elif isinstance(new_value, Manager):
+ # tags, custom fields
+ new_value = list(new_value.values_list("pk", flat=True))
+
+ LogEntry.objects.log_create(
+ instance=doc,
+ changes={
+ modified_field: [
+ old_value,
+ new_value,
+ ],
+ },
+ action=LogEntry.Action.UPDATE,
+ actor=user,
+ additional_data={
+ "reason": f"Bulk edit: {method.__name__}",
+ },
+ )
+
return Response({"result": result})
except Exception as e:
logger.warning(f"An error occurred performing bulk edit: {e!s}")
| |