From 299dac21ee646a05f802ae31d64c9d42ee514194 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:27:07 -0800 Subject: [PATCH] =?UTF-8?q?Enhancement:=20=E2=80=9Clive=E2=80=9D=20documen?= =?UTF-8?q?t=20updates=20(#12141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .mypy-baseline.txt | 5 +- pyproject.toml | 1 + .../document-detail.component.spec.ts | 234 +++++++++++++++++- .../document-detail.component.ts | 161 ++++++++++-- src-ui/src/app/data/document.ts | 8 +- .../websocket-document-updated-message.ts | 7 + .../services/websocket-status.service.spec.ts | 38 +++ .../app/services/websocket-status.service.ts | 47 +++- src/documents/apps.py | 2 + src/documents/plugins/helpers.py | 51 +++- src/documents/signals/handlers.py | 31 ++- src/documents/tasks.py | 6 + src/documents/tests/test_api_documents.py | 12 +- src/documents/tests/test_workflows.py | 34 ++- src/paperless/consumers.py | 8 + src/paperless/settings/__init__.py | 20 +- src/paperless/tests/test_websockets.py | 53 +++- 17 files changed, 648 insertions(+), 70 deletions(-) create mode 100644 src-ui/src/app/data/websocket-document-updated-message.ts diff --git a/.mypy-baseline.txt b/.mypy-baseline.txt index da962cfc2..2daa35236 100644 --- a/.mypy-baseline.txt +++ b/.mypy-baseline.txt @@ -440,9 +440,6 @@ src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | Qu src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr] src/documents/permissions.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg] src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg] -src/documents/plugins/helpers.py:0: error: "Collection[str]" has no attribute "update" [attr-defined] -src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type] -src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type] src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped] @@ -667,7 +664,6 @@ src/documents/signals/handlers.py:0: error: Argument 3 to "validate_move" has in src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type] src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type] src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type] -src/documents/signals/handlers.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] @@ -1928,6 +1924,7 @@ src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLaye src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr] src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr] src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr] +src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr] src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item] src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item] src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item] diff --git a/pyproject.toml b/pyproject.toml index 3d00f4e67..78c77eb6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -310,6 +310,7 @@ markers = [ [tool.pytest_env] PAPERLESS_DISABLE_DBHANDLER = "true" PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" +PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer" [tool.coverage.report] exclude_also = [ 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 799a08455..e4cd1941f 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 @@ -65,6 +65,7 @@ import { TagService } from 'src/app/services/rest/tag.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' +import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component' @@ -83,9 +84,9 @@ const doc: Document = { storage_path: 31, tags: [41, 42, 43], content: 'text content', - added: new Date('May 4, 2014 03:24:00'), - created: new Date('May 4, 2014 03:24:00'), - modified: new Date('May 4, 2014 03:24:00'), + added: new Date('May 4, 2014 03:24:00').toISOString(), + created: new Date('May 4, 2014 03:24:00').toISOString(), + modified: new Date('May 4, 2014 03:24:00').toISOString(), archive_serial_number: null, original_file_name: 'file.pdf', owner: null, @@ -327,6 +328,29 @@ describe('DocumentDetailComponent', () => { expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes) }) + it('should switch from preview to details when pdf preview enters the DOM', fakeAsync(() => { + component.nav = { + activeId: component.DocumentDetailNavIDs.Preview, + select: jest.fn(), + } as any + ;(component as any).pdfPreview = { + nativeElement: { offsetParent: {} }, + } + + tick() + expect(component.nav.select).toHaveBeenCalledWith( + component.DocumentDetailNavIDs.Details + ) + })) + + it('should forward title key up value to titleSubject', () => { + const subjectSpy = jest.spyOn(component.titleSubject, 'next') + + component.titleKeyUp({ target: { value: 'Updated title' } }) + + expect(subjectSpy).toHaveBeenCalledWith('Updated title') + }) + it('should change url on tab switch', () => { initNormally() const navigateSpy = jest.spyOn(router, 'navigate') @@ -524,7 +548,7 @@ describe('DocumentDetailComponent', () => { jest.spyOn(documentService, 'get').mockReturnValue( of({ ...doc, - modified: new Date('2024-01-02T00:00:00Z'), + modified: '2024-01-02T00:00:00Z', duplicate_documents: updatedDuplicates, }) ) @@ -1386,17 +1410,21 @@ describe('DocumentDetailComponent', () => { expect(errorSpy).toHaveBeenCalled() }) - it('should warn when open document does not match doc retrieved from backend on init', () => { + it('should show incoming update modal when open local draft is older than backend on init', () => { let openModal: NgbModalRef modalService.activeInstances.subscribe((modals) => (openModal = modals[0])) const modalSpy = jest.spyOn(modalService, 'open') - const openDoc = Object.assign({}, doc) + const openDoc = Object.assign({}, doc, { + __changedFields: ['title'], + }) // simulate a document being modified elsewhere and db updated - doc.modified = new Date() + const remoteDoc = Object.assign({}, doc, { + modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(), + }) jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) - jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc)) + jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc)) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) jest.spyOn(customFieldsService, 'listAll').mockReturnValue( of({ @@ -1406,11 +1434,185 @@ describe('DocumentDetailComponent', () => { }) ) fixture.detectChanges() // calls ngOnInit - expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent) - const closeSpy = jest.spyOn(openModal, 'close') + expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, { + backdrop: 'static', + }) const confirmDialog = openModal.componentInstance as ConfirmDialogComponent - confirmDialog.confirmClicked.next(confirmDialog) - expect(closeSpy).toHaveBeenCalled() + expect(confirmDialog.messageBold).toContain('Document was updated at') + }) + + it('should react to websocket document updated notifications', () => { + initNormally() + const updateMessage = { + document_id: component.documentId, + modified: '2026-02-17T00:00:00Z', + owner_id: 1, + } + const handleSpy = jest + .spyOn(component as any, 'handleIncomingDocumentUpdated') + .mockImplementation(() => {}) + const websocketStatusService = TestBed.inject(WebsocketStatusService) + + websocketStatusService.handleDocumentUpdated(updateMessage) + + expect(handleSpy).toHaveBeenCalledWith(updateMessage) + }) + + it('should queue incoming update while network is active and flush after', () => { + initNormally() + const loadSpy = jest.spyOn(component as any, 'loadDocument') + const toastSpy = jest.spyOn(toastService, 'showInfo') + + component.networkActive = true + ;(component as any).handleIncomingDocumentUpdated({ + document_id: component.documentId, + modified: '2026-02-17T00:00:00Z', + }) + + expect(loadSpy).not.toHaveBeenCalled() + + component.networkActive = false + ;(component as any).flushPendingIncomingUpdate() + + expect(loadSpy).toHaveBeenCalledWith(component.documentId, true) + expect(toastSpy).toHaveBeenCalledWith( + 'Document reloaded with latest changes.' + ) + }) + + it('should ignore queued incoming update matching local save modified', () => { + initNormally() + const loadSpy = jest.spyOn(component as any, 'loadDocument') + const toastSpy = jest.spyOn(toastService, 'showInfo') + + component.networkActive = true + ;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00' + ;(component as any).handleIncomingDocumentUpdated({ + document_id: component.documentId, + modified: '2026-02-17T00:00:00+00:00', + }) + + component.networkActive = false + ;(component as any).flushPendingIncomingUpdate() + + expect(loadSpy).not.toHaveBeenCalled() + expect(toastSpy).not.toHaveBeenCalled() + }) + + it('should clear pdf source if preview URL is empty', () => { + component.pdfSource = { url: '/preview', password: 'secret' } as any + component.previewUrl = null + ;(component as any).updatePdfSource() + + expect(component.pdfSource).toEqual({ url: null, password: undefined }) + }) + + it('should close incoming update modal if one is open', () => { + const modalRef = { close: jest.fn() } as unknown as NgbModalRef + ;(component as any).incomingUpdateModal = modalRef + ;(component as any).closeIncomingUpdateModal() + + expect(modalRef.close).toHaveBeenCalled() + expect((component as any).incomingUpdateModal).toBeNull() + }) + + it('should reload remote version when incoming update modal is confirmed', async () => { + let openModal: NgbModalRef + modalService.activeInstances.subscribe((modals) => (openModal = modals[0])) + const reloadSpy = jest + .spyOn(component as any, 'reloadRemoteVersion') + .mockImplementation(() => {}) + + ;(component as any).showIncomingUpdateModal('2026-02-17T00:00:00Z') + + const dialog = openModal.componentInstance as ConfirmDialogComponent + dialog.confirmClicked.next() + await openModal.result + + expect(dialog.buttonsEnabled).toBe(false) + expect(reloadSpy).toHaveBeenCalled() + expect((component as any).incomingUpdateModal).toBeNull() + }) + + it('should overwrite open document state when loading remote version with force', () => { + const openDoc = Object.assign({}, doc, { + title: 'Locally edited title', + __changedFields: ['title'], + }) + const remoteDoc = Object.assign({}, doc, { + title: 'Remote title', + modified: '2026-02-17T00:00:00Z', + }) + jest.spyOn(documentService, 'get').mockReturnValue(of(remoteDoc)) + jest.spyOn(documentService, 'getMetadata').mockReturnValue( + of({ + has_archive_version: false, + original_mime_type: 'application/pdf', + }) + ) + jest.spyOn(documentService, 'getSuggestions').mockReturnValue( + of({ + suggested_tags: [], + suggested_document_types: [], + suggested_correspondents: [], + }) + ) + jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) + const setDirtySpy = jest.spyOn(openDocumentsService, 'setDirty') + const saveSpy = jest.spyOn(openDocumentsService, 'save') + + ;(component as any).loadDocument(doc.id, true) + + expect(openDoc.title).toEqual('Remote title') + expect(openDoc.__changedFields).toEqual([]) + expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false) + expect(saveSpy).toHaveBeenCalled() + }) + + it('should ignore incoming update for a different document id', () => { + initNormally() + const loadSpy = jest.spyOn(component as any, 'loadDocument') + + ;(component as any).handleIncomingDocumentUpdated({ + document_id: component.documentId + 1, + modified: '2026-02-17T00:00:00Z', + }) + + expect(loadSpy).not.toHaveBeenCalled() + }) + + it('should show incoming update modal when local document has unsaved edits', () => { + initNormally() + jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true) + const modalSpy = jest + .spyOn(component as any, 'showIncomingUpdateModal') + .mockImplementation(() => {}) + + ;(component as any).handleIncomingDocumentUpdated({ + document_id: component.documentId, + modified: '2026-02-17T00:00:00Z', + }) + + expect(modalSpy).toHaveBeenCalledWith('2026-02-17T00:00:00Z') + }) + + it('should reload current document and show toast when reloading remote version', () => { + component.documentId = doc.id + const closeModalSpy = jest + .spyOn(component as any, 'closeIncomingUpdateModal') + .mockImplementation(() => {}) + const loadSpy = jest + .spyOn(component as any, 'loadDocument') + .mockImplementation(() => {}) + const notifySpy = jest.spyOn(component.docChangeNotifier, 'next') + const toastSpy = jest.spyOn(toastService, 'showInfo') + + ;(component as any).reloadRemoteVersion() + + expect(closeModalSpy).toHaveBeenCalled() + expect(notifySpy).toHaveBeenCalledWith(doc.id) + expect(loadSpy).toHaveBeenCalledWith(doc.id, true) + expect(toastSpy).toHaveBeenCalledWith('Document reloaded.') }) it('should change preview element by render type', () => { @@ -1721,6 +1923,14 @@ describe('DocumentDetailComponent', () => { expect(component.createDisabled(DataType.Tag)).toBeFalsy() }) + it('should expose add permission via userCanAdd getter', () => { + currentUserCan = true + expect(component.userCanAdd).toBeTruthy() + + currentUserCan = false + expect(component.userCanAdd).toBeFalsy() + }) + it('should call tryRenderTiff when no archive and file is tiff', () => { initNormally() const tiffRenderSpy = jest.spyOn( 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 3a3cdae00..942ed4742 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 @@ -13,6 +13,7 @@ import { NgbDateStruct, NgbDropdownModule, NgbModal, + NgbModalRef, NgbNav, NgbNavChangeEvent, NgbNavModule, @@ -80,6 +81,7 @@ import { TagService } from 'src/app/services/rest/tag.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' +import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import * as UTIF from 'utif' @@ -143,6 +145,11 @@ enum ContentRenderType { TIFF = 'tiff', } +interface IncomingDocumentUpdate { + document_id: number + modified: string +} + @Component({ selector: 'pngx-document-detail', templateUrl: './document-detail.component.html', @@ -208,6 +215,7 @@ export class DocumentDetailComponent private componentRouterService = inject(ComponentRouterService) private deviceDetectorService = inject(DeviceDetectorService) private savedViewService = inject(SavedViewService) + private readonly websocketStatusService = inject(WebsocketStatusService) @ViewChild('inputTitle') titleInput: TextComponent @@ -267,6 +275,9 @@ export class DocumentDetailComponent isDirty$: Observable unsubscribeNotifier: Subject = new Subject() docChangeNotifier: Subject = new Subject() + private incomingUpdateModal: NgbModalRef + private pendingIncomingUpdate: IncomingDocumentUpdate + private lastLocalSaveModified: string | null = null requiresPassword: boolean = false password: string @@ -475,9 +486,12 @@ export class DocumentDetailComponent ) } - private loadDocument(documentId: number): void { + private loadDocument(documentId: number, forceRemote: boolean = false): void { let redirectedToRoot = false + this.closeIncomingUpdateModal() + this.pendingIncomingUpdate = null this.selectedVersionId = documentId + this.lastLocalSaveModified = null this.previewUrl = this.documentsService.getPreviewUrl( this.selectedVersionId ) @@ -545,21 +559,25 @@ export class DocumentDetailComponent openDocument.duplicate_documents = doc.duplicate_documents this.openDocumentService.save() } - const useDoc = openDocument || doc - if (openDocument) { - if ( - new Date(doc.modified) > new Date(openDocument.modified) && - !this.modalService.hasOpenModals() - ) { - const modal = this.modalService.open(ConfirmDialogComponent) - modal.componentInstance.title = $localize`Document changes detected` - modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.` - modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.` - modal.componentInstance.cancelBtnClass = 'visually-hidden' - modal.componentInstance.btnCaption = $localize`Ok` - modal.componentInstance.confirmClicked.subscribe(() => - modal.close() - ) + let useDoc = openDocument || doc + if (openDocument && forceRemote) { + Object.assign(openDocument, doc) + openDocument.__changedFields = [] + this.openDocumentService.setDirty(openDocument, false) + this.openDocumentService.save() + useDoc = openDocument + } else if (openDocument) { + if (new Date(doc.modified) > new Date(openDocument.modified)) { + if (this.hasLocalEdits(openDocument)) { + this.showIncomingUpdateModal(doc.modified) + } else { + // No local edits to preserve, so keep the tab in sync automatically. + Object.assign(openDocument, doc) + openDocument.__changedFields = [] + this.openDocumentService.setDirty(openDocument, false) + this.openDocumentService.save() + useDoc = openDocument + } } } else { this.openDocumentService @@ -590,6 +608,98 @@ export class DocumentDetailComponent }) } + private hasLocalEdits(doc: Document): boolean { + return ( + this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length + ) + } + + private showIncomingUpdateModal(modified: string): void { + if (this.incomingUpdateModal) return + + const modal = this.modalService.open(ConfirmDialogComponent, { + backdrop: 'static', + }) + this.incomingUpdateModal = modal + + let formattedModified = null + const parsed = new Date(modified) + formattedModified = parsed.toLocaleString() + + modal.componentInstance.title = $localize`Document was updated` + modal.componentInstance.messageBold = $localize`Document was updated at ${formattedModified}.` + modal.componentInstance.message = $localize`Reload to discard your local unsaved edits and load the latest remote version.` + modal.componentInstance.btnClass = 'btn-warning' + modal.componentInstance.btnCaption = $localize`Reload` + modal.componentInstance.cancelBtnCaption = $localize`Dismiss` + + modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { + modal.componentInstance.buttonsEnabled = false + modal.close() + this.reloadRemoteVersion() + }) + modal.result.finally(() => { + this.incomingUpdateModal = null + }) + } + + private closeIncomingUpdateModal() { + if (!this.incomingUpdateModal) return + this.incomingUpdateModal.close() + this.incomingUpdateModal = null + } + + private flushPendingIncomingUpdate() { + if (!this.pendingIncomingUpdate || this.networkActive) return + const pendingUpdate = this.pendingIncomingUpdate + this.pendingIncomingUpdate = null + this.handleIncomingDocumentUpdated(pendingUpdate) + } + + private handleIncomingDocumentUpdated(data: IncomingDocumentUpdate): void { + if ( + !this.documentId || + !this.document || + data.document_id !== this.documentId + ) + return + if (this.networkActive) { + this.pendingIncomingUpdate = data + return + } + // If modified timestamp of the incoming update is the same as the last local save, + // we assume this update is from our own save and dont notify + const incomingModified = data.modified + if ( + incomingModified && + this.lastLocalSaveModified && + incomingModified === this.lastLocalSaveModified + ) { + this.lastLocalSaveModified = null + return + } + this.lastLocalSaveModified = null + + if (this.openDocumentService.isDirty(this.document)) { + this.showIncomingUpdateModal(data.modified) + } else { + this.docChangeNotifier.next(this.documentId) + this.loadDocument(this.documentId, true) + this.toastService.showInfo( + $localize`Document reloaded with latest changes.` + ) + } + } + + private reloadRemoteVersion() { + if (!this.documentId) return + + this.closeIncomingUpdateModal() + this.docChangeNotifier.next(this.documentId) + this.loadDocument(this.documentId, true) + this.toastService.showInfo($localize`Document reloaded.`) + } + ngOnInit(): void { this.setZoom( this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale @@ -648,6 +758,11 @@ export class DocumentDetailComponent this.getCustomFields() + this.websocketStatusService + .onDocumentUpdated() + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe((data) => this.handleIncomingDocumentUpdated(data)) + this.route.paramMap .pipe( filter( @@ -1033,6 +1148,7 @@ export class DocumentDetailComponent ) .subscribe({ next: (doc) => { + this.closeIncomingUpdateModal() Object.assign(this.document, doc) doc['permissions_form'] = { owner: doc.owner, @@ -1079,6 +1195,8 @@ export class DocumentDetailComponent .pipe(first()) .subscribe({ next: (docValues) => { + this.closeIncomingUpdateModal() + this.lastLocalSaveModified = docValues.modified ?? null // in case data changed while saving eg removing inbox_tags this.documentForm.patchValue(docValues) const newValues = Object.assign({}, this.documentForm.value) @@ -1093,16 +1211,19 @@ export class DocumentDetailComponent this.networkActive = false this.error = null if (close) { + this.pendingIncomingUpdate = null this.close(() => this.openDocumentService.refreshDocument(this.documentId) ) } else { this.openDocumentService.refreshDocument(this.documentId) + this.flushPendingIncomingUpdate() } this.savedViewService.maybeRefreshDocumentCounts() }, error: (error) => { this.networkActive = false + this.lastLocalSaveModified = null const canEdit = this.permissionsService.currentUserHasObjectPermissions( PermissionAction.Change, @@ -1122,6 +1243,7 @@ export class DocumentDetailComponent error ) } + this.flushPendingIncomingUpdate() }, }) } @@ -1158,8 +1280,11 @@ export class DocumentDetailComponent .pipe(first()) .subscribe({ next: ({ updateResult, nextDocId, closeResult }) => { + this.closeIncomingUpdateModal() this.error = null this.networkActive = false + this.pendingIncomingUpdate = null + this.lastLocalSaveModified = null if (closeResult && updateResult && nextDocId) { this.router.navigate(['documents', nextDocId]) this.titleInput?.focus() @@ -1167,8 +1292,10 @@ export class DocumentDetailComponent }, error: (error) => { this.networkActive = false + this.lastLocalSaveModified = null this.error = error.error this.toastService.showError($localize`Error saving document`, error) + this.flushPendingIncomingUpdate() }, }) } @@ -1254,7 +1381,7 @@ export class DocumentDetailComponent .subscribe({ next: () => { this.toastService.showInfo( - $localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.` + $localize`Reprocess operation for "${this.document.title}" will begin in the background.` ) if (modal) { modal.close() diff --git a/src-ui/src/app/data/document.ts b/src-ui/src/app/data/document.ts index 74946b332..d33b64248 100644 --- a/src-ui/src/app/data/document.ts +++ b/src-ui/src/app/data/document.ts @@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions { checksum?: string // UTC - created?: Date + created?: string // ISO string - modified?: Date + modified?: string // ISO string - added?: Date + added?: string // ISO string mime_type?: string - deleted_at?: Date + deleted_at?: string // ISO string original_file_name?: string diff --git a/src-ui/src/app/data/websocket-document-updated-message.ts b/src-ui/src/app/data/websocket-document-updated-message.ts new file mode 100644 index 000000000..bd1fe9e52 --- /dev/null +++ b/src-ui/src/app/data/websocket-document-updated-message.ts @@ -0,0 +1,7 @@ +export interface WebsocketDocumentUpdatedMessage { + document_id: number + modified: string + owner_id?: number + users_can_view?: number[] + groups_can_view?: number[] +} diff --git a/src-ui/src/app/services/websocket-status.service.spec.ts b/src-ui/src/app/services/websocket-status.service.spec.ts index 3a4115fe6..d6fdbb07d 100644 --- a/src-ui/src/app/services/websocket-status.service.spec.ts +++ b/src-ui/src/app/services/websocket-status.service.spec.ts @@ -416,4 +416,42 @@ describe('ConsumerStatusService', () => { websocketStatusService.disconnect() expect(deleted).toBeTruthy() }) + + it('should trigger updated subject on document updated', () => { + let updated = false + websocketStatusService.onDocumentUpdated().subscribe((data) => { + updated = true + expect(data.document_id).toEqual(12) + }) + + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.DOCUMENT_UPDATED, + data: { + document_id: 12, + modified: '2026-02-17T00:00:00Z', + owner_id: 1, + }, + }) + + websocketStatusService.disconnect() + expect(updated).toBeTruthy() + }) + + it('should ignore document updated events the user cannot view', () => { + let updated = false + websocketStatusService.onDocumentUpdated().subscribe(() => { + updated = true + }) + + websocketStatusService.handleDocumentUpdated({ + document_id: 12, + modified: '2026-02-17T00:00:00Z', + owner_id: 2, + users_can_view: [], + groups_can_view: [], + }) + + expect(updated).toBeFalsy() + }) }) diff --git a/src-ui/src/app/services/websocket-status.service.ts b/src-ui/src/app/services/websocket-status.service.ts index 5675060d1..9e09522b7 100644 --- a/src-ui/src/app/services/websocket-status.service.ts +++ b/src-ui/src/app/services/websocket-status.service.ts @@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core' import { Subject } from 'rxjs' import { environment } from 'src/environments/environment' import { User } from '../data/user' +import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message' import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message' import { WebsocketProgressMessage } from '../data/websocket-progress-message' import { SettingsService } from './settings.service' @@ -9,6 +10,7 @@ import { SettingsService } from './settings.service' export enum WebsocketStatusType { STATUS_UPDATE = 'status_update', DOCUMENTS_DELETED = 'documents_deleted', + DOCUMENT_UPDATED = 'document_updated', } // see ProgressStatusOptions in src/documents/plugins/helpers.py @@ -100,17 +102,20 @@ export enum UploadState { providedIn: 'root', }) export class WebsocketStatusService { - private settingsService = inject(SettingsService) + private readonly settingsService = inject(SettingsService) private statusWebSocket: WebSocket private consumerStatus: FileStatus[] = [] - private documentDetectedSubject = new Subject() - private documentConsumptionFinishedSubject = new Subject() - private documentConsumptionFailedSubject = new Subject() - private documentDeletedSubject = new Subject() - private connectionStatusSubject = new Subject() + private readonly documentDetectedSubject = new Subject() + private readonly documentConsumptionFinishedSubject = + new Subject() + private readonly documentConsumptionFailedSubject = new Subject() + private readonly documentDeletedSubject = new Subject() + private readonly documentUpdatedSubject = + new Subject() + private readonly connectionStatusSubject = new Subject() private get(taskId: string, filename?: string) { let status = @@ -176,7 +181,10 @@ export class WebsocketStatusService { data: messageData, }: { type: WebsocketStatusType - data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage + data: + | WebsocketProgressMessage + | WebsocketDocumentsDeletedMessage + | WebsocketDocumentUpdatedMessage } = JSON.parse(ev.data) switch (type) { @@ -184,6 +192,12 @@ export class WebsocketStatusService { this.documentDeletedSubject.next(true) break + case WebsocketStatusType.DOCUMENT_UPDATED: + this.handleDocumentUpdated( + messageData as WebsocketDocumentUpdatedMessage + ) + break + case WebsocketStatusType.STATUS_UPDATE: this.handleProgressUpdate(messageData as WebsocketProgressMessage) break @@ -191,7 +205,11 @@ export class WebsocketStatusService { } } - private canViewMessage(messageData: WebsocketProgressMessage): boolean { + private canViewMessage(messageData: { + owner_id?: number + users_can_view?: number[] + groups_can_view?: number[] + }): boolean { // see paperless.consumers.StatusConsumer._can_view const user: User = this.settingsService.currentUser return ( @@ -251,6 +269,15 @@ export class WebsocketStatusService { } } + handleDocumentUpdated(messageData: WebsocketDocumentUpdatedMessage) { + // fallback if backend didn't restrict message + if (!this.canViewMessage(messageData)) { + return + } + + this.documentUpdatedSubject.next(messageData) + } + fail(status: FileStatus, message: string) { status.message = message status.phase = FileStatusPhase.FAILED @@ -304,6 +331,10 @@ export class WebsocketStatusService { return this.documentDeletedSubject } + onDocumentUpdated() { + return this.documentUpdatedSubject + } + onConnectionStatus() { return this.connectionStatusSubject.asObservable() } diff --git a/src/documents/apps.py b/src/documents/apps.py index d8200edac..c14f56ee4 100644 --- a/src/documents/apps.py +++ b/src/documents/apps.py @@ -15,6 +15,7 @@ class DocumentsConfig(AppConfig): from documents.signals.handlers import add_to_index from documents.signals.handlers import run_workflows_added from documents.signals.handlers import run_workflows_updated + from documents.signals.handlers import send_websocket_document_updated from documents.signals.handlers import set_correspondent from documents.signals.handlers import set_document_type from documents.signals.handlers import set_storage_path @@ -29,6 +30,7 @@ class DocumentsConfig(AppConfig): document_consumption_finished.connect(run_workflows_added) document_consumption_finished.connect(add_or_update_document_in_llm_index) document_updated.connect(run_workflows_updated) + document_updated.connect(send_websocket_document_updated) import documents.schema # noqa: F401 diff --git a/src/documents/plugins/helpers.py b/src/documents/plugins/helpers.py index 3315ec60e..148426b81 100644 --- a/src/documents/plugins/helpers.py +++ b/src/documents/plugins/helpers.py @@ -1,4 +1,5 @@ import enum +from collections.abc import Mapping from typing import TYPE_CHECKING from asgiref.sync import async_to_sync @@ -47,7 +48,7 @@ class BaseStatusManager: async_to_sync(self._channel.flush) self._channel = None - def send(self, payload: dict[str, str | int | None]) -> None: + def send(self, payload: Mapping[str, object]) -> None: # Ensure the layer is open self.open() @@ -73,26 +74,28 @@ class ProgressManager(BaseStatusManager): max_progress: int, extra_args: dict[str, str | int | None] | None = None, ) -> None: - payload = { - "type": "status_update", - "data": { - "filename": self.filename, - "task_id": self.task_id, - "current_progress": current_progress, - "max_progress": max_progress, - "status": status, - "message": message, - }, + data: dict[str, object] = { + "filename": self.filename, + "task_id": self.task_id, + "current_progress": current_progress, + "max_progress": max_progress, + "status": status, + "message": message, } if extra_args is not None: - payload["data"].update(extra_args) + data.update(extra_args) + + payload: dict[str, object] = { + "type": "status_update", + "data": data, + } self.send(payload) class DocumentsStatusManager(BaseStatusManager): def send_documents_deleted(self, documents: list[int]) -> None: - payload = { + payload: dict[str, object] = { "type": "documents_deleted", "data": { "documents": documents, @@ -100,3 +103,25 @@ class DocumentsStatusManager(BaseStatusManager): } self.send(payload) + + def send_document_updated( + self, + *, + document_id: int, + modified: str, + owner_id: int | None = None, + users_can_view: list[int] | None = None, + groups_can_view: list[int] | None = None, + ) -> None: + payload: dict[str, object] = { + "type": "document_updated", + "data": { + "document_id": document_id, + "modified": modified, + "owner_id": owner_id, + "users_can_view": users_can_view or [], + "groups_can_view": groups_can_view or [], + }, + } + + self.send(payload) diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index a563255f0..8aa969e5b 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -24,6 +24,7 @@ from django.db.models import Q from django.dispatch import receiver from django.utils import timezone from filelock import FileLock +from rest_framework import serializers from documents import matching from documents.caching import clear_document_caches @@ -48,6 +49,7 @@ from documents.models import WorkflowAction from documents.models import WorkflowRun from documents.models import WorkflowTrigger from documents.permissions import get_objects_for_user_owner_aware +from documents.plugins.helpers import DocumentsStatusManager from documents.templating.utils import convert_format_str_to_template_format from documents.workflows.actions import build_workflow_action_context from documents.workflows.actions import execute_email_action @@ -69,6 +71,7 @@ if TYPE_CHECKING: from documents.data_models import DocumentMetadataOverrides logger = logging.getLogger("paperless.handlers") +DRF_DATETIME_FIELD = serializers.DateTimeField() def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None: @@ -764,6 +767,28 @@ def run_workflows_updated( ) +def send_websocket_document_updated( + sender, + document: Document, + **kwargs, +) -> None: + # At this point, workflows may already have applied additional changes. + document.refresh_from_db() + + from documents.data_models import DocumentMetadataOverrides + + doc_overrides = DocumentMetadataOverrides.from_document(document) + + with DocumentsStatusManager() as status_mgr: + status_mgr.send_document_updated( + document_id=document.id, + modified=DRF_DATETIME_FIELD.to_representation(document.modified), + owner_id=doc_overrides.owner_id, + users_can_view=doc_overrides.view_users, + groups_can_view=doc_overrides.view_groups, + ) + + def run_workflows( trigger_type: WorkflowTrigger.WorkflowTriggerType, document: Document | ConsumableDocument, @@ -1045,7 +1070,11 @@ def add_or_update_document_in_llm_index(sender, document, **kwargs): @receiver(models.signals.post_delete, sender=Document) -def delete_document_from_llm_index(sender, instance: Document, **kwargs): +def delete_document_from_llm_index( + sender: Any, + instance: Document, + **kwargs: Any, +) -> None: """ Delete a document from the LLM index when it is deleted. """ diff --git a/src/documents/tasks.py b/src/documents/tasks.py index abca0a6ba..ff25adbc7 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -62,6 +62,7 @@ from documents.sanity_checker import SanityCheckFailedException from documents.signals import document_updated from documents.signals.handlers import cleanup_document_deletion from documents.signals.handlers import run_workflows +from documents.signals.handlers import send_websocket_document_updated from documents.workflows.utils import get_workflows_for_trigger from paperless.config import AIConfig from paperless_ai.indexing import llm_index_add_or_update_document @@ -572,6 +573,11 @@ def check_scheduled_workflows() -> None: workflow_to_run=workflow, document=document, ) + # Scheduled workflows dont send document_updated signal, so send a websocket update here to ensure clients are updated + send_websocket_document_updated( + sender=None, + document=document, + ) def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None: diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 2d3404b28..5ddfc8538 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -1270,7 +1270,11 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): input_doc, overrides = self.get_last_consume_delay_call_args() self.assertEqual(input_doc.original_file.name, "simple.pdf") - self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents) + self.assertTrue( + input_doc.original_file.resolve(strict=False).is_relative_to( + Path(settings.SCRATCH_DIR).resolve(strict=False), + ), + ) self.assertIsNone(overrides.title) self.assertIsNone(overrides.correspondent_id) self.assertIsNone(overrides.document_type_id) @@ -1351,7 +1355,11 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): input_doc, overrides = self.get_last_consume_delay_call_args() self.assertEqual(input_doc.original_file.name, "simple.pdf") - self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents) + self.assertTrue( + input_doc.original_file.resolve(strict=False).is_relative_to( + Path(settings.SCRATCH_DIR).resolve(strict=False), + ), + ) self.assertIsNone(overrides.title) self.assertIsNone(overrides.correspondent_id) self.assertIsNone(overrides.document_type_id) diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index ca5be444b..c0f6801e9 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -643,7 +643,9 @@ class TestWorkflows( expected_str = f"Document did not match {w}" self.assertIn(expected_str, cm.output[0]) - expected_str = f"Document path {test_file} does not match" + expected_str = ( + f"Document path {Path(test_file).resolve(strict=False)} does not match" + ) self.assertIn(expected_str, cm.output[1]) def test_workflow_no_match_mail_rule(self) -> None: @@ -2010,6 +2012,36 @@ class TestWorkflows( doc.refresh_from_db() self.assertEqual(doc.owner, self.user2) + @mock.patch("documents.tasks.send_websocket_document_updated") + def test_workflow_scheduled_trigger_sends_websocket_update( + self, + mock_send_websocket_document_updated, + ) -> None: + trigger = WorkflowTrigger.objects.create( + type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED, + schedule_offset_days=1, + schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED, + ) + action = WorkflowAction.objects.create(assign_owner=self.user2) + workflow = Workflow.objects.create(name="Workflow 1", order=0) + workflow.triggers.add(trigger) + workflow.actions.add(action) + + doc = Document.objects.create( + title="sample test", + correspondent=self.c, + original_filename="sample.pdf", + created=timezone.now() - timedelta(days=2), + ) + + tasks.check_scheduled_workflows() + + self.assertEqual(mock_send_websocket_document_updated.call_count, 1) + self.assertEqual( + mock_send_websocket_document_updated.call_args.kwargs["document"].pk, + doc.pk, + ) + def test_workflow_scheduled_trigger_added(self) -> None: """ GIVEN: diff --git a/src/paperless/consumers.py b/src/paperless/consumers.py index 40f9a006f..279ceb4ed 100644 --- a/src/paperless/consumers.py +++ b/src/paperless/consumers.py @@ -1,4 +1,5 @@ import json +from typing import Any from asgiref.sync import async_to_sync from channels.exceptions import AcceptConnection @@ -52,3 +53,10 @@ class StatusConsumer(WebsocketConsumer): self.close() else: self.send(json.dumps(event)) + + def document_updated(self, event: Any) -> None: + if not self._authenticated(): + self.close() + else: + if self._can_view(event["data"]): + self.send(json.dumps(event)) diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 9f820bb04..b1f512fea 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -493,18 +493,24 @@ TEMPLATES = [ }, ] +_CHANNELS_BACKEND = os.environ.get( + "PAPERLESS_CHANNELS_BACKEND", + "channels_redis.pubsub.RedisPubSubChannelLayer", +) CHANNEL_LAYERS = { "default": { - "BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer", - "CONFIG": { - "hosts": [_CHANNELS_REDIS_URL], - "capacity": 2000, # default 100 - "expiry": 15, # default 60 - "prefix": _REDIS_KEY_PREFIX, - }, + "BACKEND": _CHANNELS_BACKEND, }, } +if _CHANNELS_BACKEND.startswith("channels_redis."): + CHANNEL_LAYERS["default"]["CONFIG"] = { + "hosts": [_CHANNELS_REDIS_URL], + "capacity": 2000, # default 100 + "expiry": 15, # default 60 + "prefix": _REDIS_KEY_PREFIX, + } + ############################################################################### # Email (SMTP) Backend # ############################################################################### diff --git a/src/paperless/tests/test_websockets.py b/src/paperless/tests/test_websockets.py index eef7d00f3..95727690b 100644 --- a/src/paperless/tests/test_websockets.py +++ b/src/paperless/tests/test_websockets.py @@ -48,6 +48,20 @@ class TestWebSockets(TestCase): mock_close.assert_called_once() mock_close.reset_mock() + message = { + "type": "document_updated", + "data": {"document_id": 10, "modified": "2026-02-17T00:00:00Z"}, + } + + await channel_layer.group_send( + "status_updates", + message, + ) + await communicator.receive_nothing() + + mock_close.assert_called_once() + mock_close.reset_mock() + message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}} await channel_layer.group_send( @@ -158,6 +172,40 @@ class TestWebSockets(TestCase): await communicator.disconnect() + @mock.patch("paperless.consumers.StatusConsumer._can_view") + @mock.patch("paperless.consumers.StatusConsumer._authenticated") + async def test_receive_document_updated(self, _authenticated, _can_view) -> None: + _authenticated.return_value = True + _can_view.return_value = True + + communicator = WebsocketCommunicator(application, "/ws/status/") + connected, _ = await communicator.connect() + self.assertTrue(connected) + + message = { + "type": "document_updated", + "data": { + "document_id": 10, + "modified": "2026-02-17T00:00:00Z", + "owner_id": 1, + "users_can_view": [1], + "groups_can_view": [], + }, + } + + channel_layer = get_channel_layer() + assert channel_layer is not None + await channel_layer.group_send( + "status_updates", + message, + ) + + response = await communicator.receive_json_from() + + self.assertEqual(response, message) + + await communicator.disconnect() + @mock.patch("channels.layers.InMemoryChannelLayer.group_send") def test_manager_send_progress(self, mock_group_send) -> None: with ProgressManager(task_id="test") as manager: @@ -190,7 +238,10 @@ class TestWebSockets(TestCase): ) @mock.patch("channels.layers.InMemoryChannelLayer.group_send") - def test_manager_send_documents_deleted(self, mock_group_send) -> None: + def test_manager_send_documents_deleted( + self, + mock_group_send: mock.MagicMock, + ) -> None: with DocumentsStatusManager() as manager: manager.send_documents_deleted([1, 2, 3])