Compare commits

..

11 Commits

Author SHA1 Message Date
shamoon
14a250e388 Use in-memory channel layers in tests 2026-02-17 15:56:38 -08:00
shamoon
79687339d9 Make sure to use same date formatting in backend 2026-02-17 13:17:46 -08:00
shamoon
d678725463 Actually these are not Dates 2026-02-17 13:01:25 -08:00
shamoon
4b7549e4a6 Dont trigger notification for regular save "echoes" 2026-02-17 12:53:36 -08:00
shamoon
e6a1a64f0a Queue updates to handle workflow triggering 2026-02-17 10:48:34 -08:00
shamoon
33d850e93c Make scheduled trigger update 2026-02-17 10:29:35 -08:00
shamoon
4943298e4c Fix test 2026-02-17 09:59:57 -08:00
shamoon
56d92d849c Actually use a modal 2026-02-17 09:45:59 -08:00
shamoon
0e9ca57d39 Frontend handle this 2026-02-17 00:46:45 -08:00
shamoon
18f3821598 Frontend service doc updated ws 2026-02-17 00:08:38 -08:00
shamoon
397f102195 Backend doc updated ws 2026-02-17 00:08:38 -08:00
15 changed files with 409 additions and 36 deletions

View File

@@ -95,7 +95,6 @@
<div class="col-md-6 col-xl-5 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="btn-toolbar mb-1 border-bottom">
<div class="btn-group pb-3">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">

View File

@@ -83,9 +83,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,
@@ -392,7 +392,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,
})
)
@@ -1205,17 +1205,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({
@@ -1225,11 +1229,52 @@ 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 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 change preview element by render type', () => {

View File

@@ -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'
@@ -142,6 +144,11 @@ enum ContentRenderType {
TIFF = 'tiff',
}
interface IncomingDocumentUpdate {
document_id: number
modified?: string
}
@Component({
selector: 'pngx-document-detail',
templateUrl: './document-detail.component.html',
@@ -205,6 +212,7 @@ export class DocumentDetailComponent
private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService)
private websocketStatusService = inject(WebsocketStatusService)
@ViewChild('inputTitle')
titleInput: TextComponent
@@ -261,6 +269,9 @@ export class DocumentDetailComponent
isDirty$: Observable<boolean>
unsubscribeNotifier: Subject<any> = new Subject()
docChangeNotifier: Subject<any> = new Subject()
private incomingUpdateModal: NgbModalRef
private pendingIncomingUpdate: IncomingDocumentUpdate
private lastLocalSaveModified: string | null = null
requiresPassword: boolean = false
password: string
@@ -432,7 +443,64 @@ export class DocumentDetailComponent
)
}
private loadDocument(documentId: number): void {
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
if (modified) {
const parsed = new Date(modified)
if (!isNaN(parsed.getTime())) {
formattedModified = parsed.toLocaleString()
}
}
modal.componentInstance.title = $localize`Document was updated.`
modal.componentInstance.messageBold = formattedModified
? $localize`Document was updated at ${formattedModified}.`
: $localize`This document was updated elsewhere.`
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 loadDocument(documentId: number, forceRemote: boolean = false): void {
this.closeIncomingUpdateModal()
this.pendingIncomingUpdate = null
this.lastLocalSaveModified = null
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.updatePdfSource()
this.http
@@ -477,21 +545,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
@@ -522,6 +594,50 @@ export class DocumentDetailComponent
})
}
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
@@ -580,6 +696,11 @@ export class DocumentDetailComponent
this.getCustomFields()
this.websocketStatusService
.onDocumentUpdated()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
this.route.paramMap
.pipe(
filter(
@@ -914,6 +1035,7 @@ export class DocumentDetailComponent
)
.subscribe({
next: (doc) => {
this.closeIncomingUpdateModal()
Object.assign(this.document, doc)
doc['permissions_form'] = {
owner: doc.owner,
@@ -960,6 +1082,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)
@@ -974,16 +1098,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,
@@ -1003,6 +1130,7 @@ export class DocumentDetailComponent
error
)
}
this.flushPendingIncomingUpdate()
},
})
}
@@ -1039,8 +1167,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()
@@ -1048,8 +1179,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()
},
})
}
@@ -1135,7 +1268,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()

View File

@@ -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

View File

@@ -0,0 +1,7 @@
export interface WebsocketDocumentUpdatedMessage {
document_id: number
modified?: string
owner_id?: number
users_can_view?: number[]
groups_can_view?: number[]
}

View File

@@ -416,4 +416,25 @@ 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()
})
})

View File

@@ -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
@@ -103,6 +105,8 @@ export class WebsocketStatusService {
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
private documentConsumptionFailedSubject = new Subject<FileStatus>()
private documentDeletedSubject = new Subject<boolean>()
private documentUpdatedSubject =
new Subject<WebsocketDocumentUpdatedMessage>()
private connectionStatusSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) {
@@ -169,7 +173,10 @@ export class WebsocketStatusService {
data: messageData,
}: {
type: WebsocketStatusType
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
data:
| WebsocketProgressMessage
| WebsocketDocumentsDeletedMessage
| WebsocketDocumentUpdatedMessage
} = JSON.parse(ev.data)
switch (type) {
@@ -177,6 +184,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
@@ -184,7 +197,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 (
@@ -244,6 +261,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
@@ -297,6 +323,10 @@ export class WebsocketStatusService {
return this.documentDeletedSubject
}
onDocumentUpdated() {
return this.documentUpdatedSubject
}
onConnectionStatus() {
return this.connectionStatusSubject.asObservable()
}

11
src/conftest.py Normal file
View File

@@ -0,0 +1,11 @@
import pytest
from pytest_django.fixtures import SettingsWrapper
@pytest.fixture(autouse=True)
def in_memory_channel_layers(settings: SettingsWrapper) -> None:
settings.CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}

View File

@@ -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

View File

@@ -100,3 +100,25 @@ class DocumentsStatusManager(BaseStatusManager):
}
self.send(payload)
def send_document_updated(
self,
*,
document_id: int,
modified: str | None = None,
owner_id: int | None = None,
users_can_view: list[int] | None = None,
groups_can_view: list[int] | None = None,
) -> None:
payload = {
"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)

View File

@@ -23,6 +23,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
@@ -45,6 +46,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
@@ -63,6 +65,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:
@@ -753,6 +756,30 @@ 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)
if document.modified
else None,
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,

View File

@@ -60,6 +60,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
@@ -534,6 +535,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:

View File

@@ -1965,6 +1965,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:

View File

@@ -52,3 +52,10 @@ class StatusConsumer(WebsocketConsumer):
self.close()
else:
self.send(json.dumps(event))
def document_updated(self, event) -> None:
if not self._authenticated():
self.close()
else:
if self._can_view(event["data"]):
self.send(json.dumps(event))

View File

@@ -158,6 +158,39 @@ 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()
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: