From 6eb847987112f145a096a380ee524690fccbe0d2 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 2 Feb 2025 22:02:31 -0800 Subject: [PATCH] Use different channels, refactor because now its not just consumer --- src-ui/messages.xlf | 232 +++++------ src-ui/src/app/app.component.spec.ts | 22 +- src-ui/src/app/app.component.ts | 14 +- .../saved-view-widget.component.spec.ts | 14 +- .../saved-view-widget.component.ts | 6 +- .../statistics-widget.component.spec.ts | 10 +- .../statistics-widget.component.ts | 6 +- .../upload-file-widget.component.spec.ts | 26 +- .../upload-file-widget.component.ts | 35 +- .../document-list.component.spec.ts | 16 +- .../document-list/document-list.component.ts | 8 +- .../websocket-documents-deleted-message.ts | 3 + ...ssage.ts => websocket-progress-message.ts} | 2 +- .../services/consumer-status.service.spec.ts | 343 ---------------- .../services/upload-documents.service.spec.ts | 26 +- .../app/services/upload-documents.service.ts | 16 +- .../services/websocket-status.service.spec.ts | 375 ++++++++++++++++++ ...service.ts => websocket-status.service.ts} | 123 +++--- src/documents/bulk_edit.py | 12 +- src/documents/plugins/helpers.py | 47 ++- src/paperless/consumers.py | 8 +- src/paperless/tests/test_websockets.py | 4 +- 22 files changed, 710 insertions(+), 638 deletions(-) create mode 100644 src-ui/src/app/data/websocket-documents-deleted-message.ts rename src-ui/src/app/data/{websocket-consumer-status-message.ts => websocket-progress-message.ts} (77%) delete mode 100644 src-ui/src/app/services/consumer-status.service.spec.ts create mode 100644 src-ui/src/app/services/websocket-status.service.spec.ts rename src-ui/src/app/services/{consumer-status.service.ts => websocket-status.service.ts} (73%) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index fdd32af00..9983e6b55 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -9233,122 +9233,6 @@ 11 - - Document already exists. - - src/app/services/consumer-status.service.ts - 17 - - - - Document already exists. Note: existing document is in the trash. - - src/app/services/consumer-status.service.ts - 18 - - - - Document with ASN already exists. - - src/app/services/consumer-status.service.ts - 19 - - - - Document with ASN already exists. Note: existing document is in the trash. - - src/app/services/consumer-status.service.ts - 20 - - - - File not found. - - src/app/services/consumer-status.service.ts - 21 - - - - Pre-consume script does not exist. - - src/app/services/consumer-status.service.ts - 22 - - Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation - - - Error while executing pre-consume script. - - src/app/services/consumer-status.service.ts - 23 - - Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation - - - Post-consume script does not exist. - - src/app/services/consumer-status.service.ts - 24 - - Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation - - - Error while executing post-consume script. - - src/app/services/consumer-status.service.ts - 25 - - Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation - - - Received new file. - - src/app/services/consumer-status.service.ts - 26 - - - - File type not supported. - - src/app/services/consumer-status.service.ts - 27 - - - - Processing document... - - src/app/services/consumer-status.service.ts - 28 - - - - Generating thumbnail... - - src/app/services/consumer-status.service.ts - 29 - - - - Retrieving date from document... - - src/app/services/consumer-status.service.ts - 30 - - - - Saving document... - - src/app/services/consumer-status.service.ts - 31 - - - - Finished. - - src/app/services/consumer-status.service.ts - 32 - - You have unsaved changes to the document @@ -9664,6 +9548,122 @@ 70 + + Document already exists. + + src/app/services/websocket-status.service.ts + 23 + + + + Document already exists. Note: existing document is in the trash. + + src/app/services/websocket-status.service.ts + 24 + + + + Document with ASN already exists. + + src/app/services/websocket-status.service.ts + 25 + + + + Document with ASN already exists. Note: existing document is in the trash. + + src/app/services/websocket-status.service.ts + 26 + + + + File not found. + + src/app/services/websocket-status.service.ts + 27 + + + + Pre-consume script does not exist. + + src/app/services/websocket-status.service.ts + 28 + + Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation + + + Error while executing pre-consume script. + + src/app/services/websocket-status.service.ts + 29 + + Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation + + + Post-consume script does not exist. + + src/app/services/websocket-status.service.ts + 30 + + Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation + + + Error while executing post-consume script. + + src/app/services/websocket-status.service.ts + 31 + + Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation + + + Received new file. + + src/app/services/websocket-status.service.ts + 32 + + + + File type not supported. + + src/app/services/websocket-status.service.ts + 33 + + + + Processing document... + + src/app/services/websocket-status.service.ts + 34 + + + + Generating thumbnail... + + src/app/services/websocket-status.service.ts + 35 + + + + Retrieving date from document... + + src/app/services/websocket-status.service.ts + 36 + + + + Saving document... + + src/app/services/websocket-status.service.ts + 37 + + + + Finished. + + src/app/services/websocket-status.service.ts + 38 + + diff --git a/src-ui/src/app/app.component.spec.ts b/src-ui/src/app/app.component.spec.ts index 74626f847..bc59f78dc 100644 --- a/src-ui/src/app/app.component.spec.ts +++ b/src-ui/src/app/app.component.spec.ts @@ -18,20 +18,20 @@ import { ToastsComponent } from './components/common/toasts/toasts.component' import { FileDropComponent } from './components/file-drop/file-drop.component' import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' import { PermissionsGuard } from './guards/permissions.guard' -import { - ConsumerStatusService, - FileStatus, -} from './services/consumer-status.service' import { HotKeyService } from './services/hot-key.service' import { PermissionsService } from './services/permissions.service' import { SettingsService } from './services/settings.service' import { Toast, ToastService } from './services/toast.service' +import { + FileStatus, + WebsocketStatusService, +} from './services/websocket-status.service' describe('AppComponent', () => { let component: AppComponent let fixture: ComponentFixture let tourService: TourService - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService let permissionsService: PermissionsService let toastService: ToastService let router: Router @@ -59,7 +59,7 @@ describe('AppComponent', () => { }).compileComponents() tourService = TestBed.inject(TourService) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) permissionsService = TestBed.inject(PermissionsService) settingsService = TestBed.inject(SettingsService) toastService = TestBed.inject(ToastService) @@ -90,7 +90,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'show') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) component.ngOnInit() const status = new FileStatus() @@ -109,7 +109,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'show') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) @@ -122,7 +122,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'show') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentDetected') + .spyOn(websocketStatusService, 'onDocumentDetected') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) @@ -136,7 +136,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'show') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentDetected') + .spyOn(websocketStatusService, 'onDocumentDetected') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) @@ -148,7 +148,7 @@ describe('AppComponent', () => { const toastSpy = jest.spyOn(toastService, 'showError') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFailed') + .spyOn(websocketStatusService, 'onDocumentConsumptionFailed') .mockReturnValue(fileStatusSubject) component.ngOnInit() fileStatusSubject.next(new FileStatus()) diff --git a/src-ui/src/app/app.component.ts b/src-ui/src/app/app.component.ts index c89f5d4c2..a6c4702b7 100644 --- a/src-ui/src/app/app.component.ts +++ b/src-ui/src/app/app.component.ts @@ -6,7 +6,6 @@ import { ToastsComponent } from './components/common/toasts/toasts.component' import { FileDropComponent } from './components/file-drop/file-drop.component' import { SETTINGS_KEYS } from './data/ui-settings' import { ComponentRouterService } from './services/component-router.service' -import { ConsumerStatusService } from './services/consumer-status.service' import { HotKeyService } from './services/hot-key.service' import { PermissionAction, @@ -16,6 +15,7 @@ import { import { SettingsService } from './services/settings.service' import { TasksService } from './services/tasks.service' import { ToastService } from './services/toast.service' +import { WebsocketStatusService } from './services/websocket-status.service' @Component({ selector: 'pngx-root', @@ -35,7 +35,7 @@ export class AppComponent implements OnInit, OnDestroy { constructor( private settings: SettingsService, - private consumerStatusService: ConsumerStatusService, + private websocketStatusService: WebsocketStatusService, private toastService: ToastService, private router: Router, private tasksService: TasksService, @@ -51,7 +51,7 @@ export class AppComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.consumerStatusService.disconnect() + this.websocketStatusService.disconnect() if (this.successSubscription) { this.successSubscription.unsubscribe() } @@ -76,9 +76,9 @@ export class AppComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.consumerStatusService.connect() + this.websocketStatusService.connect() - this.successSubscription = this.consumerStatusService + this.successSubscription = this.websocketStatusService .onDocumentConsumptionFinished() .subscribe((status) => { this.tasksService.reload() @@ -108,7 +108,7 @@ export class AppComponent implements OnInit, OnDestroy { } }) - this.failedSubscription = this.consumerStatusService + this.failedSubscription = this.websocketStatusService .onDocumentConsumptionFailed() .subscribe((status) => { this.tasksService.reload() @@ -121,7 +121,7 @@ export class AppComponent implements OnInit, OnDestroy { } }) - this.newDocumentSubscription = this.consumerStatusService + this.newDocumentSubscription = this.websocketStatusService .onDocumentDetected() .subscribe((status) => { this.tasksService.reload() diff --git a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts index 5f66c68d6..621a90491 100644 --- a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts +++ b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.spec.ts @@ -33,14 +33,14 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe' -import { - ConsumerStatusService, - FileStatus, -} from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { PermissionsService } from 'src/app/services/permissions.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentService } from 'src/app/services/rest/document.service' +import { + FileStatus, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { SavedViewWidgetComponent } from './saved-view-widget.component' @@ -112,7 +112,7 @@ describe('SavedViewWidgetComponent', () => { let component: SavedViewWidgetComponent let fixture: ComponentFixture let documentService: DocumentService - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService let documentListViewService: DocumentListViewService let router: Router @@ -176,7 +176,7 @@ describe('SavedViewWidgetComponent', () => { }).compileComponents() documentService = TestBed.inject(DocumentService) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) documentListViewService = TestBed.inject(DocumentListViewService) router = TestBed.inject(Router) fixture = TestBed.createComponent(SavedViewWidgetComponent) @@ -235,7 +235,7 @@ describe('SavedViewWidgetComponent', () => { it('should reload on document consumption finished', () => { const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) const reloadSpy = jest.spyOn(component, 'reload') component.ngOnInit() diff --git a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts index 7f6c5755b..32bf7a004 100644 --- a/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts +++ b/src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts @@ -42,7 +42,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe' import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe' import { UsernamePipe } from 'src/app/pipes/username.pipe' -import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { @@ -53,6 +52,7 @@ import { import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { DocumentService } from 'src/app/services/rest/document.service' import { SettingsService } from 'src/app/services/settings.service' +import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' @Component({ @@ -94,7 +94,7 @@ export class SavedViewWidgetComponent private documentService: DocumentService, private router: Router, private list: DocumentListViewService, - private consumerStatusService: ConsumerStatusService, + private websocketStatusService: WebsocketStatusService, public openDocumentsService: OpenDocumentsService, public documentListViewService: DocumentListViewService, public permissionsService: PermissionsService, @@ -124,7 +124,7 @@ export class SavedViewWidgetComponent ngOnInit(): void { this.reload() this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE - this.consumerStatusService + this.websocketStatusService .onDocumentConsumptionFinished() .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { diff --git a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts index da0c2c083..48ca50a10 100644 --- a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts +++ b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.spec.ts @@ -12,9 +12,9 @@ import { routes } from 'src/app/app-routing.module' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { - ConsumerStatusService, FileStatus, -} from 'src/app/services/consumer-status.service' + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { StatisticsWidgetComponent } from './statistics-widget.component' @@ -23,7 +23,7 @@ describe('StatisticsWidgetComponent', () => { let component: StatisticsWidgetComponent let fixture: ComponentFixture let httpTestingController: HttpTestingController - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService const fileStatusSubject = new Subject() beforeEach(async () => { @@ -44,9 +44,9 @@ describe('StatisticsWidgetComponent', () => { }).compileComponents() fixture = TestBed.createComponent(StatisticsWidgetComponent) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) component = fixture.componentInstance diff --git a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts index f54852429..0669a3666 100644 --- a/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts +++ b/src-ui/src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts @@ -8,8 +8,8 @@ import { first, Subject, Subscription, takeUntil } from 'rxjs' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' import { FILTER_HAS_TAGS_ANY } from 'src/app/data/filter-rule-type' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' -import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' +import { WebsocketStatusService } from 'src/app/services/websocket-status.service' import { environment } from 'src/environments/environment' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' @@ -51,7 +51,7 @@ export class StatisticsWidgetComponent constructor( private http: HttpClient, - private consumerStatusService: ConsumerStatusService, + private websocketConnectionService: WebsocketStatusService, private documentListViewService: DocumentListViewService ) { super() @@ -109,7 +109,7 @@ export class StatisticsWidgetComponent ngOnInit(): void { this.reload() - this.subscription = this.consumerStatusService + this.subscription = this.websocketConnectionService .onDocumentConsumptionFinished() .subscribe(() => { this.reload() diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts index cc1591966..45ac9217a 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.spec.ts @@ -12,13 +12,13 @@ import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { routes } from 'src/app/app-routing.module' import { PermissionsGuard } from 'src/app/guards/permissions.guard' -import { - ConsumerStatusService, - FileStatus, - FileStatusPhase, -} from 'src/app/services/consumer-status.service' import { PermissionsService } from 'src/app/services/permissions.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service' +import { + FileStatus, + FileStatusPhase, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { UploadFileWidgetComponent } from './upload-file-widget.component' const FAILED_STATUSES = [new FileStatus()] @@ -42,7 +42,7 @@ const DEFAULT_STATUSES = [ describe('UploadFileWidgetComponent', () => { let component: UploadFileWidgetComponent let fixture: ComponentFixture - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService let uploadDocumentsService: UploadDocumentsService beforeEach(async () => { @@ -65,7 +65,7 @@ describe('UploadFileWidgetComponent', () => { ], }).compileComponents() - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) uploadDocumentsService = TestBed.inject(UploadDocumentsService) fixture = TestBed.createComponent(UploadFileWidgetComponent) component = fixture.componentInstance @@ -91,14 +91,14 @@ describe('UploadFileWidgetComponent', () => { }) it('should generate stats summary', () => { - mockConsumerStatuses(consumerStatusService) + mockConsumerStatuses(websocketStatusService) expect(component.getStatusSummary()).toEqual( 'Processing: 6, Failed: 1, Added: 4' ) }) it('should report an upload progress summary', () => { - mockConsumerStatuses(consumerStatusService) + mockConsumerStatuses(websocketStatusService) expect(component.getTotalUploadProgress()).toEqual(0.75) }) @@ -117,7 +117,7 @@ describe('UploadFileWidgetComponent', () => { }) it('should enforce a maximum number of alerts', () => { - mockConsumerStatuses(consumerStatusService) + mockConsumerStatuses(websocketStatusService) fixture.detectChanges() // 5 total, 1 hidden expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength( @@ -131,19 +131,19 @@ describe('UploadFileWidgetComponent', () => { }) it('should allow dismissing an alert', () => { - const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss') + const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') component.dismiss(new FileStatus()) expect(dismissSpy).toHaveBeenCalled() }) it('should allow dismissing completed alerts', fakeAsync(() => { - mockConsumerStatuses(consumerStatusService) + mockConsumerStatuses(websocketStatusService) component.alertsExpanded = true fixture.detectChanges() jest .spyOn(component, 'getStatusCompleted') .mockImplementation(() => SUCCESS_STATUSES) - const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss') + const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss') component.dismissCompleted() tick(1000) fixture.detectChanges() diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts index f237ab7aa..f60cdce60 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.ts @@ -12,13 +12,13 @@ import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' -import { - ConsumerStatusService, - FileStatus, - FileStatusPhase, -} from 'src/app/services/consumer-status.service' import { SettingsService } from 'src/app/services/settings.service' import { UploadDocumentsService } from 'src/app/services/upload-documents.service' +import { + FileStatus, + FileStatusPhase, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' const MAX_ALERTS = 5 @@ -46,7 +46,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { @ViewChildren(NgbAlert) alerts: QueryList constructor( - private consumerStatusService: ConsumerStatusService, + private websocketStatusService: WebsocketStatusService, private uploadDocumentsService: UploadDocumentsService, public settingsService: SettingsService ) { @@ -54,13 +54,13 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { } getStatus() { - return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS) + return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS) } getStatusSummary() { let strings = [] let countUploadingAndProcessing = - this.consumerStatusService.getConsumerStatusNotCompleted().length + this.websocketStatusService.getConsumerStatusNotCompleted().length let countFailed = this.getStatusFailed().length let countSuccess = this.getStatusSuccess().length if (countUploadingAndProcessing > 0) { @@ -78,27 +78,30 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { } getStatusHidden() { - if (this.consumerStatusService.getConsumerStatus().length < MAX_ALERTS) + if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS) return [] - else return this.consumerStatusService.getConsumerStatus().slice(MAX_ALERTS) + else + return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS) } getStatusUploading() { - return this.consumerStatusService.getConsumerStatus( + return this.websocketStatusService.getConsumerStatus( FileStatusPhase.UPLOADING ) } getStatusFailed() { - return this.consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) + return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) } getStatusSuccess() { - return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS) + return this.websocketStatusService.getConsumerStatus( + FileStatusPhase.SUCCESS + ) } getStatusCompleted() { - return this.consumerStatusService.getConsumerStatusCompleted() + return this.websocketStatusService.getConsumerStatusCompleted() } getTotalUploadProgress() { @@ -134,12 +137,12 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions { } dismiss(status: FileStatus) { - this.consumerStatusService.dismiss(status) + this.websocketStatusService.dismiss(status) } dismissCompleted() { this.getStatusCompleted().forEach((status) => - this.consumerStatusService.dismiss(status) + this.websocketStatusService.dismiss(status) ) } 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 e0fb7e09f..13a938f59 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 @@ -38,16 +38,16 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe' import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' import { UsernamePipe } from 'src/app/pipes/username.pipe' -import { - ConsumerStatusService, - FileStatus, -} from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { PermissionsService } from 'src/app/services/permissions.service' import { DocumentService } from 'src/app/services/rest/document.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' +import { + FileStatus, + WebsocketStatusService, +} from 'src/app/services/websocket-status.service' import { DocumentCardLargeComponent } from './document-card-large/document-card-large.component' import { DocumentCardSmallComponent } from './document-card-small/document-card-small.component' import { DocumentListComponent } from './document-list.component' @@ -81,7 +81,7 @@ describe('DocumentListComponent', () => { let fixture: ComponentFixture let documentListService: DocumentListViewService let documentService: DocumentService - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService let savedViewService: SavedViewService let router: Router let activatedRoute: ActivatedRoute @@ -112,7 +112,7 @@ describe('DocumentListComponent', () => { documentListService = TestBed.inject(DocumentListViewService) documentService = TestBed.inject(DocumentService) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) savedViewService = TestBed.inject(SavedViewService) router = TestBed.inject(Router) activatedRoute = TestBed.inject(ActivatedRoute) @@ -128,7 +128,7 @@ describe('DocumentListComponent', () => { const reloadSpy = jest.spyOn(documentListService, 'reload') const fileStatusSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentConsumptionFinished') + .spyOn(websocketStatusService, 'onDocumentConsumptionFinished') .mockReturnValue(fileStatusSubject) fixture.detectChanges() fileStatusSubject.next(new FileStatus()) @@ -139,7 +139,7 @@ describe('DocumentListComponent', () => { const reloadSpy = jest.spyOn(documentListService, 'reload') const documentDeletedSubject = new Subject() jest - .spyOn(consumerStatusService, 'onDocumentDeleted') + .spyOn(websocketStatusService, 'onDocumentDeleted') .mockReturnValue(documentDeletedSubject) fixture.detectChanges() documentDeletedSubject.next(true) diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index 138ec5ab1..e1f71edbc 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -43,7 +43,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe' import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe' import { UsernamePipe } from 'src/app/pipes/username.pipe' -import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { HotKeyService } from 'src/app/services/hot-key.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' @@ -51,6 +50,7 @@ import { PermissionsService } from 'src/app/services/permissions.service' import { SavedViewService } from 'src/app/services/rest/saved-view.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 { filterRulesDiffer, isFullTextFilterRule, @@ -113,7 +113,7 @@ export class DocumentListComponent private router: Router, private toastService: ToastService, private modalService: NgbModal, - private consumerStatusService: ConsumerStatusService, + private websocketStatusService: WebsocketStatusService, public openDocumentsService: OpenDocumentsService, public settingsService: SettingsService, private hotKeyService: HotKeyService, @@ -234,14 +234,14 @@ export class DocumentListComponent } ngOnInit(): void { - this.consumerStatusService + this.websocketStatusService .onDocumentConsumptionFinished() .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(() => { this.list.reload() }) - this.consumerStatusService.onDocumentDeleted().subscribe(() => { + this.websocketStatusService.onDocumentDeleted().subscribe(() => { this.list.reload() }) diff --git a/src-ui/src/app/data/websocket-documents-deleted-message.ts b/src-ui/src/app/data/websocket-documents-deleted-message.ts new file mode 100644 index 000000000..11ded3781 --- /dev/null +++ b/src-ui/src/app/data/websocket-documents-deleted-message.ts @@ -0,0 +1,3 @@ +export interface WebsocketDocumentsDeletedMessage { + documents: number[] +} diff --git a/src-ui/src/app/data/websocket-consumer-status-message.ts b/src-ui/src/app/data/websocket-progress-message.ts similarity index 77% rename from src-ui/src/app/data/websocket-consumer-status-message.ts rename to src-ui/src/app/data/websocket-progress-message.ts index d1ac590b1..c8e37e232 100644 --- a/src-ui/src/app/data/websocket-consumer-status-message.ts +++ b/src-ui/src/app/data/websocket-progress-message.ts @@ -1,4 +1,4 @@ -export interface WebsocketConsumerStatusMessage { +export interface WebsocketProgressMessage { filename?: string task_id?: string current_progress?: number diff --git a/src-ui/src/app/services/consumer-status.service.spec.ts b/src-ui/src/app/services/consumer-status.service.spec.ts deleted file mode 100644 index 9cdb622bc..000000000 --- a/src-ui/src/app/services/consumer-status.service.spec.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { - HttpEventType, - HttpResponse, - provideHttpClient, - withInterceptorsFromDi, -} from '@angular/common/http' -import { - HttpTestingController, - provideHttpClientTesting, -} from '@angular/common/http/testing' -import { TestBed } from '@angular/core/testing' -import WS from 'jest-websocket-mock' -import { environment } from 'src/environments/environment' -import { - ConsumerStatusService, - FILE_STATUS_MESSAGES, - FileStatusPhase, -} from './consumer-status.service' -import { DocumentService } from './rest/document.service' -import { SettingsService } from './settings.service' - -describe('ConsumerStatusService', () => { - let httpTestingController: HttpTestingController - let consumerStatusService: ConsumerStatusService - let documentService: DocumentService - let settingsService: SettingsService - - const server = new WS( - `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`, - { jsonProtocol: true } - ) - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - providers: [ - ConsumerStatusService, - DocumentService, - SettingsService, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - ], - }) - - httpTestingController = TestBed.inject(HttpTestingController) - settingsService = TestBed.inject(SettingsService) - settingsService.currentUser = { - id: 1, - username: 'testuser', - is_superuser: false, - } - consumerStatusService = TestBed.inject(ConsumerStatusService) - documentService = TestBed.inject(DocumentService) - }) - - afterEach(() => { - httpTestingController.verify() - }) - - it('should update status on websocket processing progress', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - expect(status.getProgress()).toEqual(0) - - consumerStatusService.connect() - - consumerStatusService - .onDocumentConsumptionFinished() - .subscribe((filestatus) => { - expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS) - }) - - consumerStatusService.onDocumentDetected().subscribe((filestatus) => { - expect(filestatus.phase).toEqual(FileStatusPhase.STARTED) - }) - - server.send({ - task_id, - filename: 'file.pdf', - current_progress: 50, - max_progress: 100, - document_id: 12, - status: 'WORKING', - }) - - expect(status.getProgress()).toBeCloseTo(0.6) // (0.8 * 50/100) + .2 - expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([ - status, - ]) - - server.send({ - task_id, - filename: 'file.pdf', - current_progress: 100, - max_progress: 100, - document_id: 12, - status: 'SUCCESS', - message: FILE_STATUS_MESSAGES.finished, - }) - - expect(status.getProgress()).toEqual(1) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 0 - ) - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1) - - consumerStatusService.disconnect() - }) - - it('should update status on websocket failed progress', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - status.taskId = task_id - consumerStatusService.connect() - - consumerStatusService - .onDocumentConsumptionFailed() - .subscribe((filestatus) => { - expect(filestatus.phase).toEqual(FileStatusPhase.FAILED) - }) - - server.send({ - task_id, - filename: 'file.pdf', - current_progress: 50, - max_progress: 100, - document_id: 12, - }) - - expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([ - status, - ]) - - server.send({ - task_id, - filename: 'file.pdf', - current_progress: 50, - max_progress: 100, - document_id: 12, - status: 'FAILED', - message: FILE_STATUS_MESSAGES.document_already_exists, - }) - - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 0 - ) - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1) - }) - - it('should update status on upload progress', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - - documentService.uploadDocument({}).subscribe((event) => { - if (event.type === HttpEventType.Response) { - status.taskId = event.body['task_id'] - status.message = $localize`Upload complete, waiting...` - } else if (event.type === HttpEventType.UploadProgress) { - status.updateProgress( - FileStatusPhase.UPLOADING, - event.loaded, - event.total - ) - } - }) - - const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/post_document/` - ) - - req.event( - new HttpResponse({ - body: { - task_id, - }, - }) - ) - - req.event({ - type: HttpEventType.UploadProgress, - loaded: 100, - total: 300, - }) - - expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) - ).toEqual([status]) - expect(consumerStatusService.getConsumerStatus()).toEqual([status]) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([ - status, - ]) - - req.event({ - type: HttpEventType.UploadProgress, - loaded: 300, - total: 300, - }) - - expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300 - }) - - it('should support dismiss completed', () => { - consumerStatusService.connect() - server.send({ - task_id: '1234', - filename: 'file.pdf', - current_progress: 100, - max_progress: 100, - document_id: 12, - status: 'SUCCESS', - message: 'finished', - }) - - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1) - consumerStatusService.dismissCompleted() - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0) - consumerStatusService.disconnect() - }) - - it('should support dismiss', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - status.taskId = task_id - status.updateProgress(FileStatusPhase.UPLOADING, 50, 100) - - const status2 = consumerStatusService.newFileUpload('file2.pdf') - status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100) - - expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) - ).toEqual([status, status2]) - expect(consumerStatusService.getConsumerStatus()).toEqual([status, status2]) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([ - status, - status2, - ]) - - consumerStatusService.dismiss(status) - expect(consumerStatusService.getConsumerStatus()).toEqual([status2]) - - consumerStatusService.dismiss(status2) - expect(consumerStatusService.getConsumerStatus()).toHaveLength(0) - }) - - it('should support fail', () => { - const task_id = '1234' - const status = consumerStatusService.newFileUpload('file.pdf') - status.taskId = task_id - status.updateProgress(FileStatusPhase.UPLOADING, 50, 100) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 1 - ) - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0) - consumerStatusService.fail(status, 'fail') - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 0 - ) - expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1) - }) - - it('should notify of document created on status message without upload', () => { - let detected = false - consumerStatusService.onDocumentDetected().subscribe((filestatus) => { - expect(filestatus.phase).toEqual(FileStatusPhase.STARTED) - detected = true - }) - - consumerStatusService.connect() - server.send({ - task_id: '1234', - filename: 'file.pdf', - current_progress: 0, - max_progress: 100, - message: 'new_file', - status: 'STARTED', - }) - - consumerStatusService.disconnect() - expect(detected).toBeTruthy() - }) - - it('should notify of document in progress without upload', () => { - consumerStatusService.connect() - server.send({ - task_id: '1234', - filename: 'file.pdf', - current_progress: 50, - max_progress: 100, - docuement_id: 12, - status: 'WORKING', - }) - - consumerStatusService.disconnect() - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 1 - ) - }) - - it('should not notify current user if document has different expected owner', () => { - consumerStatusService.connect() - server.send({ - task_id: '1234', - filename: 'file1.pdf', - current_progress: 50, - max_progress: 100, - docuement_id: 12, - owner_id: 1, - status: 'WORKING', - }) - - server.send({ - task_id: '5678', - filename: 'file2.pdf', - current_progress: 50, - max_progress: 100, - docuement_id: 13, - owner_id: 2, - status: 'WORKING', - }) - - consumerStatusService.disconnect() - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( - 1 - ) - }) - - it('should trigger deleted subject on document deleted', () => { - let deleted = false - consumerStatusService.onDocumentDeleted().subscribe(() => { - deleted = true - }) - - consumerStatusService.connect() - server.send({ - current_progress: 1, - max_progress: 1, - status: 'DELETED', - }) - - consumerStatusService.disconnect() - expect(deleted).toBeTruthy() - }) -}) diff --git a/src-ui/src/app/services/upload-documents.service.spec.ts b/src-ui/src/app/services/upload-documents.service.spec.ts index cf0812306..28fb5b2e0 100644 --- a/src-ui/src/app/services/upload-documents.service.spec.ts +++ b/src-ui/src/app/services/upload-documents.service.spec.ts @@ -9,11 +9,11 @@ import { } from '@angular/common/http/testing' import { TestBed } from '@angular/core/testing' import { environment } from 'src/environments/environment' -import { - ConsumerStatusService, - FileStatusPhase, -} from './consumer-status.service' import { UploadDocumentsService } from './upload-documents.service' +import { + FileStatusPhase, + WebsocketStatusService, +} from './websocket-status.service' const files = [ { @@ -45,14 +45,14 @@ const fileList = { describe('UploadDocumentsService', () => { let httpTestingController: HttpTestingController let uploadDocumentsService: UploadDocumentsService - let consumerStatusService: ConsumerStatusService + let websocketStatusService: WebsocketStatusService beforeEach(() => { TestBed.configureTestingModule({ imports: [], providers: [ UploadDocumentsService, - ConsumerStatusService, + WebsocketStatusService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), ], @@ -60,7 +60,7 @@ describe('UploadDocumentsService', () => { httpTestingController = TestBed.inject(HttpTestingController) uploadDocumentsService = TestBed.inject(UploadDocumentsService) - consumerStatusService = TestBed.inject(ConsumerStatusService) + websocketStatusService = TestBed.inject(WebsocketStatusService) }) afterEach(() => { @@ -80,11 +80,11 @@ describe('UploadDocumentsService', () => { it('updates progress during upload and failure', () => { uploadDocumentsService.uploadFiles(fileList) - expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( 2 ) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) + websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) ).toHaveLength(0) const req = httpTestingController.match( @@ -98,7 +98,7 @@ describe('UploadDocumentsService', () => { }) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) + websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) ).toHaveLength(1) }) @@ -110,7 +110,7 @@ describe('UploadDocumentsService', () => { ) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) + websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) ).toHaveLength(0) req[0].flush( @@ -122,7 +122,7 @@ describe('UploadDocumentsService', () => { ) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) + websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) ).toHaveLength(1) uploadDocumentsService.uploadFiles(fileList) @@ -140,7 +140,7 @@ describe('UploadDocumentsService', () => { ) expect( - consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) + websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED) ).toHaveLength(2) }) diff --git a/src-ui/src/app/services/upload-documents.service.ts b/src-ui/src/app/services/upload-documents.service.ts index 8a5e42b47..602e6d8ae 100644 --- a/src-ui/src/app/services/upload-documents.service.ts +++ b/src-ui/src/app/services/upload-documents.service.ts @@ -2,11 +2,11 @@ import { HttpEventType } from '@angular/common/http' import { Injectable } from '@angular/core' import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop' import { Subscription } from 'rxjs' -import { - ConsumerStatusService, - FileStatusPhase, -} from './consumer-status.service' import { DocumentService } from './rest/document.service' +import { + FileStatusPhase, + WebsocketStatusService, +} from './websocket-status.service' @Injectable({ providedIn: 'root', @@ -16,7 +16,7 @@ export class UploadDocumentsService { constructor( private documentService: DocumentService, - private consumerStatusService: ConsumerStatusService + private websocketStatusService: WebsocketStatusService ) {} onNgxFileDrop(files: NgxFileDropEntry[]) { @@ -37,7 +37,7 @@ export class UploadDocumentsService { private uploadFile(file: File) { let formData = new FormData() formData.append('document', file, file.name) - let status = this.consumerStatusService.newFileUpload(file.name) + let status = this.websocketStatusService.newFileUpload(file.name) status.message = $localize`Connecting...` @@ -61,11 +61,11 @@ export class UploadDocumentsService { error: (error) => { switch (error.status) { case 400: { - this.consumerStatusService.fail(status, error.error.document) + this.websocketStatusService.fail(status, error.error.document) break } default: { - this.consumerStatusService.fail( + this.websocketStatusService.fail( status, $localize`HTTP error: ${error.status} ${error.statusText}` ) diff --git a/src-ui/src/app/services/websocket-status.service.spec.ts b/src-ui/src/app/services/websocket-status.service.spec.ts new file mode 100644 index 000000000..d3bf71f7e --- /dev/null +++ b/src-ui/src/app/services/websocket-status.service.spec.ts @@ -0,0 +1,375 @@ +import { + HttpEventType, + HttpResponse, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http' +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import WS from 'jest-websocket-mock' +import { environment } from 'src/environments/environment' +import { DocumentService } from './rest/document.service' +import { SettingsService } from './settings.service' +import { + FILE_STATUS_MESSAGES, + FileStatusPhase, + WebsocketStatusService, + WebsocketStatusType, +} from './websocket-status.service' + +describe('ConsumerStatusService', () => { + let httpTestingController: HttpTestingController + let websocketStatusService: WebsocketStatusService + let documentService: DocumentService + let settingsService: SettingsService + + const server = new WS( + `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`, + { jsonProtocol: true } + ) + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + WebsocketStatusService, + DocumentService, + SettingsService, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }) + + httpTestingController = TestBed.inject(HttpTestingController) + settingsService = TestBed.inject(SettingsService) + settingsService.currentUser = { + id: 1, + username: 'testuser', + is_superuser: false, + } + websocketStatusService = TestBed.inject(WebsocketStatusService) + documentService = TestBed.inject(DocumentService) + }) + + afterEach(() => { + httpTestingController.verify() + }) + + it('should update status on websocket processing progress', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + expect(status.getProgress()).toEqual(0) + + websocketStatusService.connect() + + websocketStatusService + .onDocumentConsumptionFinished() + .subscribe((filestatus) => { + expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS) + }) + + websocketStatusService.onDocumentDetected().subscribe((filestatus) => { + expect(filestatus.phase).toEqual(FileStatusPhase.STARTED) + }) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id, + filename: 'file.pdf', + current_progress: 50, + max_progress: 100, + document_id: 12, + status: 'WORKING', + }, + }) + + expect(status.getProgress()).toBeCloseTo(0.6) // (0.8 * 50/100) + .2 + expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([ + status, + ]) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id, + filename: 'file.pdf', + current_progress: 100, + max_progress: 100, + document_id: 12, + status: 'SUCCESS', + message: FILE_STATUS_MESSAGES.finished, + }, + }) + + expect(status.getProgress()).toEqual(1) + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 0 + ) + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1) + + websocketStatusService.disconnect() + }) + + it('should update status on websocket failed progress', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + status.taskId = task_id + websocketStatusService.connect() + + websocketStatusService + .onDocumentConsumptionFailed() + .subscribe((filestatus) => { + expect(filestatus.phase).toEqual(FileStatusPhase.FAILED) + }) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id, + filename: 'file.pdf', + current_progress: 50, + max_progress: 100, + document_id: 12, + }, + }) + + expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([ + status, + ]) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id, + filename: 'file.pdf', + current_progress: 50, + max_progress: 100, + document_id: 12, + status: 'FAILED', + message: FILE_STATUS_MESSAGES.document_already_exists, + }, + }) + + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 0 + ) + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1) + }) + + it('should update status on upload progress', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + + documentService.uploadDocument({}).subscribe((event) => { + if (event.type === HttpEventType.Response) { + status.taskId = event.body['task_id'] + status.message = $localize`Upload complete, waiting...` + } else if (event.type === HttpEventType.UploadProgress) { + status.updateProgress( + FileStatusPhase.UPLOADING, + event.loaded, + event.total + ) + } + }) + + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/post_document/` + ) + + req.event( + new HttpResponse({ + body: { + task_id, + }, + }) + ) + + req.event({ + type: HttpEventType.UploadProgress, + loaded: 100, + total: 300, + }) + + expect( + websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) + ).toEqual([status]) + expect(websocketStatusService.getConsumerStatus()).toEqual([status]) + expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([ + status, + ]) + + req.event({ + type: HttpEventType.UploadProgress, + loaded: 300, + total: 300, + }) + + expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300 + }) + + it('should support dismiss completed', () => { + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '1234', + filename: 'file.pdf', + current_progress: 100, + max_progress: 100, + document_id: 12, + status: 'SUCCESS', + message: 'finished', + }, + }) + + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1) + websocketStatusService.dismissCompleted() + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(0) + websocketStatusService.disconnect() + }) + + it('should support dismiss', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + status.taskId = task_id + status.updateProgress(FileStatusPhase.UPLOADING, 50, 100) + + const status2 = websocketStatusService.newFileUpload('file2.pdf') + status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100) + + expect( + websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) + ).toEqual([status, status2]) + expect(websocketStatusService.getConsumerStatus()).toEqual([ + status, + status2, + ]) + expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([ + status, + status2, + ]) + + websocketStatusService.dismiss(status) + expect(websocketStatusService.getConsumerStatus()).toEqual([status2]) + + websocketStatusService.dismiss(status2) + expect(websocketStatusService.getConsumerStatus()).toHaveLength(0) + }) + + it('should support fail', () => { + const task_id = '1234' + const status = websocketStatusService.newFileUpload('file.pdf') + status.taskId = task_id + status.updateProgress(FileStatusPhase.UPLOADING, 50, 100) + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 1 + ) + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(0) + websocketStatusService.fail(status, 'fail') + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 0 + ) + expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1) + }) + + it('should notify of document created on status message without upload', () => { + let detected = false + websocketStatusService.onDocumentDetected().subscribe((filestatus) => { + expect(filestatus.phase).toEqual(FileStatusPhase.STARTED) + detected = true + }) + + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '1234', + filename: 'file.pdf', + current_progress: 0, + max_progress: 100, + message: 'new_file', + status: 'STARTED', + }, + }) + + websocketStatusService.disconnect() + expect(detected).toBeTruthy() + }) + + it('should notify of document in progress without upload', () => { + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '1234', + filename: 'file.pdf', + current_progress: 50, + max_progress: 100, + docuement_id: 12, + status: 'WORKING', + }, + }) + + websocketStatusService.disconnect() + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 1 + ) + }) + + it('should not notify current user if document has different expected owner', () => { + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '1234', + filename: 'file1.pdf', + current_progress: 50, + max_progress: 100, + docuement_id: 12, + owner_id: 1, + status: 'WORKING', + }, + }) + + server.send({ + type: WebsocketStatusType.STATUS_UPDATE, + data: { + task_id: '5678', + filename: 'file2.pdf', + current_progress: 50, + max_progress: 100, + docuement_id: 13, + owner_id: 2, + status: 'WORKING', + }, + }) + + websocketStatusService.disconnect() + expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength( + 1 + ) + }) + + it('should trigger deleted subject on document deleted', () => { + let deleted = false + websocketStatusService.onDocumentDeleted().subscribe(() => { + deleted = true + }) + + websocketStatusService.connect() + server.send({ + type: WebsocketStatusType.DOCUMENTS_DELETED, + data: { + documents: [1, 2, 3], + }, + }) + + websocketStatusService.disconnect() + expect(deleted).toBeTruthy() + }) +}) diff --git a/src-ui/src/app/services/consumer-status.service.ts b/src-ui/src/app/services/websocket-status.service.ts similarity index 73% rename from src-ui/src/app/services/consumer-status.service.ts rename to src-ui/src/app/services/websocket-status.service.ts index a3e83f947..f73b0c868 100644 --- a/src-ui/src/app/services/consumer-status.service.ts +++ b/src-ui/src/app/services/websocket-status.service.ts @@ -1,9 +1,15 @@ import { Injectable } from '@angular/core' import { Subject } from 'rxjs' import { environment } from 'src/environments/environment' -import { WebsocketConsumerStatusMessage } from '../data/websocket-consumer-status-message' +import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message' +import { WebsocketProgressMessage } from '../data/websocket-progress-message' import { SettingsService } from './settings.service' +export enum WebsocketStatusType { + STATUS_UPDATE = 'status_update', + DOCUMENTS_DELETED = 'documents_deleted', +} + // see ProgressStatusOptions in src/documents/plugins/helpers.py export enum FileStatusPhase { STARTED = 0, @@ -85,7 +91,7 @@ export class FileStatus { @Injectable({ providedIn: 'root', }) -export class ConsumerStatusService { +export class WebsocketStatusService { constructor(private settingsService: SettingsService) {} private statusWebSocket: WebSocket @@ -146,61 +152,22 @@ export class ConsumerStatusService { this.statusWebSocket = new WebSocket( `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/` ) - this.statusWebSocket.onmessage = (ev) => { - let statusMessage: WebsocketConsumerStatusMessage = JSON.parse(ev['data']) + this.statusWebSocket.onmessage = (ev: MessageEvent) => { + const { + type, + data: messageData, + }: { + type: WebsocketStatusType + data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage + } = JSON.parse(ev.data) - // special case for documents deletion - if (statusMessage.status === 'DELETED') { - this.documentDeletedSubject.next(true) - return - } - - // fallback if backend didn't restrict message - if ( - statusMessage.owner_id && - statusMessage.owner_id !== this.settingsService.currentUser?.id && - !this.settingsService.currentUser?.is_superuser - ) { - return - } - - let statusMessageGet = this.get( - statusMessage.task_id, - statusMessage.filename - ) - let status = statusMessageGet.status - let created = statusMessageGet.created - - status.updateProgress( - FileStatusPhase.WORKING, - statusMessage.current_progress, - statusMessage.max_progress - ) - if ( - statusMessage.message && - statusMessage.message in FILE_STATUS_MESSAGES - ) { - status.message = FILE_STATUS_MESSAGES[statusMessage.message] - } else if (statusMessage.message) { - status.message = statusMessage.message - } - status.documentId = statusMessage.document_id - - if (statusMessage.status in FileStatusPhase) { - status.phase = FileStatusPhase[statusMessage.status] - } - - switch (status.phase) { - case FileStatusPhase.STARTED: - if (created) this.documentDetectedSubject.next(status) + switch (type) { + case WebsocketStatusType.STATUS_UPDATE: + this.handleProgressUpdate(messageData as WebsocketProgressMessage) break - case FileStatusPhase.SUCCESS: - this.documentConsumptionFinishedSubject.next(status) - break - - case FileStatusPhase.FAILED: - this.documentConsumptionFailedSubject.next(status) + case WebsocketStatusType.DOCUMENTS_DELETED: + this.documentDeletedSubject.next(true) break default: @@ -209,6 +176,54 @@ export class ConsumerStatusService { } } + handleProgressUpdate(messageData: WebsocketProgressMessage) { + // fallback if backend didn't restrict message + if ( + messageData.owner_id && + messageData.owner_id !== this.settingsService.currentUser?.id && + !this.settingsService.currentUser?.is_superuser + ) { + return + } + + let statusMessageGet = this.get(messageData.task_id, messageData.filename) + let status = statusMessageGet.status + let created = statusMessageGet.created + + status.updateProgress( + FileStatusPhase.WORKING, + messageData.current_progress, + messageData.max_progress + ) + if (messageData.message && messageData.message in FILE_STATUS_MESSAGES) { + status.message = FILE_STATUS_MESSAGES[messageData.message] + } else if (messageData.message) { + status.message = messageData.message + } + status.documentId = messageData.document_id + + if (messageData.status in FileStatusPhase) { + status.phase = FileStatusPhase[messageData.status] + } + + switch (status.phase) { + case FileStatusPhase.STARTED: + if (created) this.documentDetectedSubject.next(status) + break + + case FileStatusPhase.SUCCESS: + this.documentConsumptionFinishedSubject.next(status) + break + + case FileStatusPhase.FAILED: + this.documentConsumptionFailedSubject.next(status) + break + + default: + break + } + } + fail(status: FileStatus, message: string) { status.message = message status.phase = FileStatusPhase.FAILED diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 13a430a9f..0aadcc295 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -24,8 +24,7 @@ from documents.models import Document from documents.models import DocumentType from documents.models import StoragePath from documents.permissions import set_permissions_for_object -from documents.plugins.helpers import ProgressManager -from documents.plugins.helpers import ProgressStatusOptions +from documents.plugins.helpers import DocumentsStatusManager from documents.tasks import bulk_update_documents from documents.tasks import consume_file from documents.tasks import update_document_content_maybe_archive_file @@ -222,13 +221,8 @@ def delete(doc_ids: list[int]) -> Literal["OK"]: for id in doc_ids: index.remove_document_by_id(writer, id) - status_mgr = ProgressManager() - status_mgr.send_progress( - status=ProgressStatusOptions.DELETED, - message="Documents deleted", - current_progress=1, - max_progress=1, - ) + status_mgr = DocumentsStatusManager() + status_mgr.send_documents_deleted(doc_ids) except Exception as e: if "Data too long for column" in str(e): logger.warning( diff --git a/src/documents/plugins/helpers.py b/src/documents/plugins/helpers.py index 6a04fcad8..3315ec60e 100644 --- a/src/documents/plugins/helpers.py +++ b/src/documents/plugins/helpers.py @@ -13,19 +13,16 @@ class ProgressStatusOptions(str, enum.Enum): WORKING = "WORKING" SUCCESS = "SUCCESS" FAILED = "FAILED" - DELETED = "DELETED" -class ProgressManager: +class BaseStatusManager: """ Handles sending of progress information via the channel layer, with proper management of the open/close of the layer to ensure messages go out and everything is cleaned up """ - def __init__(self, filename: str | None = None, task_id: str | None = None) -> None: - self.filename = filename + def __init__(self) -> None: self._channel: RedisPubSubChannelLayer | None = None - self.task_id = task_id def __enter__(self): self.open() @@ -50,6 +47,24 @@ class ProgressManager: async_to_sync(self._channel.flush) self._channel = None + def send(self, payload: dict[str, str | int | None]) -> None: + # Ensure the layer is open + self.open() + + # Just for IDEs + if TYPE_CHECKING: + assert self._channel is not None + + # Construct and send the update + async_to_sync(self._channel.group_send)("status_updates", payload) + + +class ProgressManager(BaseStatusManager): + def __init__(self, filename: str | None = None, task_id: str | None = None) -> None: + super().__init__() + self.filename = filename + self.task_id = task_id + def send_progress( self, status: ProgressStatusOptions, @@ -58,13 +73,6 @@ class ProgressManager: max_progress: int, extra_args: dict[str, str | int | None] | None = None, ) -> None: - # Ensure the layer is open - self.open() - - # Just for IDEs - if TYPE_CHECKING: - assert self._channel is not None - payload = { "type": "status_update", "data": { @@ -79,5 +87,16 @@ class ProgressManager: if extra_args is not None: payload["data"].update(extra_args) - # Construct and send the update - async_to_sync(self._channel.group_send)("status_updates", payload) + self.send(payload) + + +class DocumentsStatusManager(BaseStatusManager): + def send_documents_deleted(self, documents: list[int]) -> None: + payload = { + "type": "documents_deleted", + "data": { + "documents": documents, + }, + } + + self.send(payload) diff --git a/src/paperless/consumers.py b/src/paperless/consumers.py index cf1a3b548..c72b58aa7 100644 --- a/src/paperless/consumers.py +++ b/src/paperless/consumers.py @@ -41,4 +41,10 @@ class StatusConsumer(WebsocketConsumer): self.close() else: if self._is_owner_or_unowned(event["data"]): - self.send(json.dumps(event["data"])) + self.send(json.dumps(event)) + + def documents_deleted(self, event): + if not self._authenticated(): + self.close() + else: + self.send(json.dumps(event)) diff --git a/src/paperless/tests/test_websockets.py b/src/paperless/tests/test_websockets.py index bf838821a..161bee4e6 100644 --- a/src/paperless/tests/test_websockets.py +++ b/src/paperless/tests/test_websockets.py @@ -40,12 +40,12 @@ class TestWebSockets(TestCase): connected, subprotocol = await communicator.connect() self.assertTrue(connected) - message = {"task_id": "test"} + message = {"type": "status_update", "data": {"task_id": "test"}} channel_layer = get_channel_layer() await channel_layer.group_send( "status_updates", - {"type": "status_update", "data": message}, + message, ) response = await communicator.receive_json_from()