mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-24 00:59:35 -06:00
Compare commits
3 Commits
feature-li
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ee564673 | ||
|
|
fa13ca7a42 | ||
|
|
814f57b099 |
2
.github/workflows/ci-backend.yml
vendored
2
.github/workflows/ci-backend.yml
vendored
@@ -129,6 +129,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
uv pip list
|
uv pip list
|
||||||
- name: Check typing (pyrefly)
|
- name: Check typing (pyrefly)
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
uv run pyrefly \
|
uv run pyrefly \
|
||||||
check \
|
check \
|
||||||
@@ -143,6 +144,7 @@ jobs:
|
|||||||
${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
|
${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
|
||||||
${{ runner.os }}-mypy-
|
${{ runner.os }}-mypy-
|
||||||
- name: Check typing (mypy)
|
- name: Check typing (mypy)
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
uv run mypy \
|
uv run mypy \
|
||||||
--show-error-codes \
|
--show-error-codes \
|
||||||
|
|||||||
@@ -450,6 +450,9 @@ src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | Qu
|
|||||||
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr]
|
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr]
|
||||||
src/documents/permissions.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg]
|
src/documents/permissions.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg]
|
||||||
src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg]
|
||||||
|
src/documents/plugins/helpers.py:0: error: "Collection[str]" has no attribute "update" [attr-defined]
|
||||||
|
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
|
||||||
|
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
|
||||||
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||||
@@ -673,6 +676,7 @@ src/documents/signals/handlers.py:0: error: Argument 3 to "validate_move" has in
|
|||||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||||
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
|
||||||
|
src/documents/signals/handlers.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||||
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||||
@@ -1941,7 +1945,6 @@ src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLaye
|
|||||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
||||||
src/paperless/tests/test_websockets.py:0: error: Item "None" of "BaseChannelLayer | None" has no attribute "group_send" [union-attr]
|
|
||||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||||
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
src/paperless/tests/test_websockets.py:0: error: TypedDict "_WebsocketTestScope" has no key "user" [typeddict-item]
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.10.0-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.10.5-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
<div class="col-md-6 col-xl-5 mb-4">
|
<div class="col-md-6 col-xl-5 mb-4">
|
||||||
|
|
||||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||||
|
|
||||||
<div class="btn-toolbar mb-1 border-bottom">
|
<div class="btn-toolbar mb-1 border-bottom">
|
||||||
<div class="btn-group pb-3">
|
<div class="btn-group pb-3">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
@@ -84,9 +83,9 @@ const doc: Document = {
|
|||||||
storage_path: 31,
|
storage_path: 31,
|
||||||
tags: [41, 42, 43],
|
tags: [41, 42, 43],
|
||||||
content: 'text content',
|
content: 'text content',
|
||||||
added: new Date('May 4, 2014 03:24:00').toISOString(),
|
added: new Date('May 4, 2014 03:24:00'),
|
||||||
created: new Date('May 4, 2014 03:24:00').toISOString(),
|
created: new Date('May 4, 2014 03:24:00'),
|
||||||
modified: new Date('May 4, 2014 03:24:00').toISOString(),
|
modified: new Date('May 4, 2014 03:24:00'),
|
||||||
archive_serial_number: null,
|
archive_serial_number: null,
|
||||||
original_file_name: 'file.pdf',
|
original_file_name: 'file.pdf',
|
||||||
owner: null,
|
owner: null,
|
||||||
@@ -307,29 +306,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes)
|
expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should switch from preview to details when pdf preview enters the DOM', fakeAsync(() => {
|
|
||||||
component.nav = {
|
|
||||||
activeId: component.DocumentDetailNavIDs.Preview,
|
|
||||||
select: jest.fn(),
|
|
||||||
} as any
|
|
||||||
;(component as any).pdfPreview = {
|
|
||||||
nativeElement: { offsetParent: {} },
|
|
||||||
}
|
|
||||||
|
|
||||||
tick()
|
|
||||||
expect(component.nav.select).toHaveBeenCalledWith(
|
|
||||||
component.DocumentDetailNavIDs.Details
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
|
|
||||||
it('should forward title key up value to titleSubject', () => {
|
|
||||||
const subjectSpy = jest.spyOn(component.titleSubject, 'next')
|
|
||||||
|
|
||||||
component.titleKeyUp({ target: { value: 'Updated title' } })
|
|
||||||
|
|
||||||
expect(subjectSpy).toHaveBeenCalledWith('Updated title')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should change url on tab switch', () => {
|
it('should change url on tab switch', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
@@ -416,7 +392,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
jest.spyOn(documentService, 'get').mockReturnValue(
|
jest.spyOn(documentService, 'get').mockReturnValue(
|
||||||
of({
|
of({
|
||||||
...doc,
|
...doc,
|
||||||
modified: '2024-01-02T00:00:00Z',
|
modified: new Date('2024-01-02T00:00:00Z'),
|
||||||
duplicate_documents: updatedDuplicates,
|
duplicate_documents: updatedDuplicates,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -1229,21 +1205,17 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(errorSpy).toHaveBeenCalled()
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show incoming update modal when open local draft is older than backend on init', () => {
|
it('should warn when open document does not match doc retrieved from backend on init', () => {
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
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
|
// simulate a document being modified elsewhere and db updated
|
||||||
const remoteDoc = Object.assign({}, doc, {
|
doc.modified = new Date()
|
||||||
modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
|
|
||||||
})
|
|
||||||
jest
|
jest
|
||||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||||
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
|
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
|
||||||
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc))
|
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
|
||||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||||
of({
|
of({
|
||||||
@@ -1253,185 +1225,11 @@ describe('DocumentDetailComponent', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
fixture.detectChanges() // calls ngOnInit
|
fixture.detectChanges() // calls ngOnInit
|
||||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, {
|
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
|
||||||
backdrop: 'static',
|
const closeSpy = jest.spyOn(openModal, 'close')
|
||||||
})
|
|
||||||
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
|
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
|
||||||
expect(confirmDialog.messageBold).toContain('Document was updated at')
|
confirmDialog.confirmClicked.next(confirmDialog)
|
||||||
})
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
it('should react to websocket document updated notifications', () => {
|
|
||||||
initNormally()
|
|
||||||
const updateMessage = {
|
|
||||||
document_id: component.documentId,
|
|
||||||
modified: '2026-02-17T00:00:00Z',
|
|
||||||
owner_id: 1,
|
|
||||||
}
|
|
||||||
const handleSpy = jest
|
|
||||||
.spyOn(component as any, 'handleIncomingDocumentUpdated')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
const websocketStatusService = TestBed.inject(WebsocketStatusService)
|
|
||||||
|
|
||||||
websocketStatusService.handleDocumentUpdated(updateMessage)
|
|
||||||
|
|
||||||
expect(handleSpy).toHaveBeenCalledWith(updateMessage)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should queue incoming update while network is active and flush after', () => {
|
|
||||||
initNormally()
|
|
||||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
|
||||||
|
|
||||||
component.networkActive = true
|
|
||||||
;(component as any).handleIncomingDocumentUpdated({
|
|
||||||
document_id: component.documentId,
|
|
||||||
modified: '2026-02-17T00:00:00Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(loadSpy).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
component.networkActive = false
|
|
||||||
;(component as any).flushPendingIncomingUpdate()
|
|
||||||
|
|
||||||
expect(loadSpy).toHaveBeenCalledWith(component.documentId, true)
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Document reloaded with latest changes.'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ignore queued incoming update matching local save modified', () => {
|
|
||||||
initNormally()
|
|
||||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
|
||||||
|
|
||||||
component.networkActive = true
|
|
||||||
;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00'
|
|
||||||
;(component as any).handleIncomingDocumentUpdated({
|
|
||||||
document_id: component.documentId,
|
|
||||||
modified: '2026-02-17T00:00:00+00:00',
|
|
||||||
})
|
|
||||||
|
|
||||||
component.networkActive = false
|
|
||||||
;(component as any).flushPendingIncomingUpdate()
|
|
||||||
|
|
||||||
expect(loadSpy).not.toHaveBeenCalled()
|
|
||||||
expect(toastSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should clear pdf source if preview URL is empty', () => {
|
|
||||||
component.pdfSource = { url: '/preview', password: 'secret' } as any
|
|
||||||
component.previewUrl = null
|
|
||||||
;(component as any).updatePdfSource()
|
|
||||||
|
|
||||||
expect(component.pdfSource).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should close incoming update modal if one is open', () => {
|
|
||||||
const modalRef = { close: jest.fn() } as unknown as NgbModalRef
|
|
||||||
;(component as any).incomingUpdateModal = modalRef
|
|
||||||
;(component as any).closeIncomingUpdateModal()
|
|
||||||
|
|
||||||
expect(modalRef.close).toHaveBeenCalled()
|
|
||||||
expect((component as any).incomingUpdateModal).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should reload remote version when incoming update modal is confirmed', async () => {
|
|
||||||
let openModal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
|
||||||
const reloadSpy = jest
|
|
||||||
.spyOn(component as any, 'reloadRemoteVersion')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
|
|
||||||
;(component as any).showIncomingUpdateModal('2026-02-17T00:00:00Z')
|
|
||||||
|
|
||||||
const dialog = openModal.componentInstance as ConfirmDialogComponent
|
|
||||||
dialog.confirmClicked.next()
|
|
||||||
await openModal.result
|
|
||||||
|
|
||||||
expect(dialog.buttonsEnabled).toBe(false)
|
|
||||||
expect(reloadSpy).toHaveBeenCalled()
|
|
||||||
expect((component as any).incomingUpdateModal).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should overwrite open document state when loading remote version with force', () => {
|
|
||||||
const openDoc = Object.assign({}, doc, {
|
|
||||||
title: 'Locally edited title',
|
|
||||||
__changedFields: ['title'],
|
|
||||||
})
|
|
||||||
const remoteDoc = Object.assign({}, doc, {
|
|
||||||
title: 'Remote title',
|
|
||||||
modified: '2026-02-17T00:00:00Z',
|
|
||||||
})
|
|
||||||
jest.spyOn(documentService, 'get').mockReturnValue(of(remoteDoc))
|
|
||||||
jest.spyOn(documentService, 'getMetadata').mockReturnValue(
|
|
||||||
of({
|
|
||||||
has_archive_version: false,
|
|
||||||
original_mime_type: 'application/pdf',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
jest.spyOn(documentService, 'getSuggestions').mockReturnValue(
|
|
||||||
of({
|
|
||||||
suggested_tags: [],
|
|
||||||
suggested_document_types: [],
|
|
||||||
suggested_correspondents: [],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
|
||||||
const setDirtySpy = jest.spyOn(openDocumentsService, 'setDirty')
|
|
||||||
const saveSpy = jest.spyOn(openDocumentsService, 'save')
|
|
||||||
|
|
||||||
;(component as any).loadDocument(doc.id, true)
|
|
||||||
|
|
||||||
expect(openDoc.title).toEqual('Remote title')
|
|
||||||
expect(openDoc.__changedFields).toEqual([])
|
|
||||||
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
|
|
||||||
expect(saveSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ignore incoming update for a different document id', () => {
|
|
||||||
initNormally()
|
|
||||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
|
||||||
|
|
||||||
;(component as any).handleIncomingDocumentUpdated({
|
|
||||||
document_id: component.documentId + 1,
|
|
||||||
modified: '2026-02-17T00:00:00Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(loadSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show incoming update modal when local document has unsaved edits', () => {
|
|
||||||
initNormally()
|
|
||||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
|
||||||
const modalSpy = jest
|
|
||||||
.spyOn(component as any, 'showIncomingUpdateModal')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
|
|
||||||
;(component as any).handleIncomingDocumentUpdated({
|
|
||||||
document_id: component.documentId,
|
|
||||||
modified: '2026-02-17T00:00:00Z',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(modalSpy).toHaveBeenCalledWith('2026-02-17T00:00:00Z')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should reload current document and show toast when reloading remote version', () => {
|
|
||||||
component.documentId = doc.id
|
|
||||||
const closeModalSpy = jest
|
|
||||||
.spyOn(component as any, 'closeIncomingUpdateModal')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
const loadSpy = jest
|
|
||||||
.spyOn(component as any, 'loadDocument')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
|
||||||
|
|
||||||
;(component as any).reloadRemoteVersion()
|
|
||||||
|
|
||||||
expect(closeModalSpy).toHaveBeenCalled()
|
|
||||||
expect(notifySpy).toHaveBeenCalledWith(doc.id)
|
|
||||||
expect(loadSpy).toHaveBeenCalledWith(doc.id, true)
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should change preview element by render type', () => {
|
it('should change preview element by render type', () => {
|
||||||
@@ -1680,14 +1478,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should expose add permission via userCanAdd getter', () => {
|
|
||||||
currentUserCan = true
|
|
||||||
expect(component.userCanAdd).toBeTruthy()
|
|
||||||
|
|
||||||
currentUserCan = false
|
|
||||||
expect(component.userCanAdd).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call tryRenderTiff when no archive and file is tiff', () => {
|
it('should call tryRenderTiff when no archive and file is tiff', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const tiffRenderSpy = jest.spyOn(
|
const tiffRenderSpy = jest.spyOn(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
NgbDateStruct,
|
NgbDateStruct,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbModal,
|
NgbModal,
|
||||||
NgbModalRef,
|
|
||||||
NgbNav,
|
NgbNav,
|
||||||
NgbNavChangeEvent,
|
NgbNavChangeEvent,
|
||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
@@ -81,7 +80,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.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 { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||||
import * as UTIF from 'utif'
|
import * as UTIF from 'utif'
|
||||||
@@ -144,11 +142,6 @@ enum ContentRenderType {
|
|||||||
TIFF = 'tiff',
|
TIFF = 'tiff',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IncomingDocumentUpdate {
|
|
||||||
document_id: number
|
|
||||||
modified: string
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-document-detail',
|
selector: 'pngx-document-detail',
|
||||||
templateUrl: './document-detail.component.html',
|
templateUrl: './document-detail.component.html',
|
||||||
@@ -212,7 +205,6 @@ export class DocumentDetailComponent
|
|||||||
private componentRouterService = inject(ComponentRouterService)
|
private componentRouterService = inject(ComponentRouterService)
|
||||||
private deviceDetectorService = inject(DeviceDetectorService)
|
private deviceDetectorService = inject(DeviceDetectorService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
|
||||||
|
|
||||||
@ViewChild('inputTitle')
|
@ViewChild('inputTitle')
|
||||||
titleInput: TextComponent
|
titleInput: TextComponent
|
||||||
@@ -269,9 +261,6 @@ export class DocumentDetailComponent
|
|||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
docChangeNotifier: Subject<any> = new Subject()
|
docChangeNotifier: Subject<any> = new Subject()
|
||||||
private incomingUpdateModal: NgbModalRef
|
|
||||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
|
||||||
private lastLocalSaveModified: string | null = null
|
|
||||||
|
|
||||||
requiresPassword: boolean = false
|
requiresPassword: boolean = false
|
||||||
password: string
|
password: string
|
||||||
@@ -443,58 +432,7 @@ export class DocumentDetailComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasLocalEdits(doc: Document): boolean {
|
private loadDocument(documentId: number): void {
|
||||||
return (
|
|
||||||
this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private showIncomingUpdateModal(modified: string): void {
|
|
||||||
if (this.incomingUpdateModal) return
|
|
||||||
|
|
||||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
|
||||||
backdrop: 'static',
|
|
||||||
})
|
|
||||||
this.incomingUpdateModal = modal
|
|
||||||
|
|
||||||
let formattedModified = null
|
|
||||||
const parsed = new Date(modified)
|
|
||||||
formattedModified = parsed.toLocaleString()
|
|
||||||
|
|
||||||
modal.componentInstance.title = $localize`Document was updated`
|
|
||||||
modal.componentInstance.messageBold = $localize`Document was updated at ${formattedModified}.`
|
|
||||||
modal.componentInstance.message = $localize`Reload to discard your local unsaved edits and load the latest remote version.`
|
|
||||||
modal.componentInstance.btnClass = 'btn-warning'
|
|
||||||
modal.componentInstance.btnCaption = $localize`Reload`
|
|
||||||
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
|
||||||
|
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
|
||||||
modal.componentInstance.buttonsEnabled = false
|
|
||||||
modal.close()
|
|
||||||
this.reloadRemoteVersion()
|
|
||||||
})
|
|
||||||
modal.result.finally(() => {
|
|
||||||
this.incomingUpdateModal = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private closeIncomingUpdateModal() {
|
|
||||||
if (!this.incomingUpdateModal) return
|
|
||||||
this.incomingUpdateModal.close()
|
|
||||||
this.incomingUpdateModal = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private flushPendingIncomingUpdate() {
|
|
||||||
if (!this.pendingIncomingUpdate || this.networkActive) return
|
|
||||||
const pendingUpdate = this.pendingIncomingUpdate
|
|
||||||
this.pendingIncomingUpdate = null
|
|
||||||
this.handleIncomingDocumentUpdated(pendingUpdate)
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadDocument(documentId: number, forceRemote: boolean = false): void {
|
|
||||||
this.closeIncomingUpdateModal()
|
|
||||||
this.pendingIncomingUpdate = null
|
|
||||||
this.lastLocalSaveModified = null
|
|
||||||
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
|
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
|
||||||
this.updatePdfSource()
|
this.updatePdfSource()
|
||||||
this.http
|
this.http
|
||||||
@@ -539,25 +477,21 @@ export class DocumentDetailComponent
|
|||||||
openDocument.duplicate_documents = doc.duplicate_documents
|
openDocument.duplicate_documents = doc.duplicate_documents
|
||||||
this.openDocumentService.save()
|
this.openDocumentService.save()
|
||||||
}
|
}
|
||||||
let useDoc = openDocument || doc
|
const useDoc = openDocument || doc
|
||||||
if (openDocument && forceRemote) {
|
if (openDocument) {
|
||||||
Object.assign(openDocument, doc)
|
if (
|
||||||
openDocument.__changedFields = []
|
new Date(doc.modified) > new Date(openDocument.modified) &&
|
||||||
this.openDocumentService.setDirty(openDocument, false)
|
!this.modalService.hasOpenModals()
|
||||||
this.openDocumentService.save()
|
) {
|
||||||
useDoc = openDocument
|
const modal = this.modalService.open(ConfirmDialogComponent)
|
||||||
} else if (openDocument) {
|
modal.componentInstance.title = $localize`Document changes detected`
|
||||||
if (new Date(doc.modified) > new Date(openDocument.modified)) {
|
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
|
||||||
if (this.hasLocalEdits(openDocument)) {
|
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.`
|
||||||
this.showIncomingUpdateModal(doc.modified)
|
modal.componentInstance.cancelBtnClass = 'visually-hidden'
|
||||||
} else {
|
modal.componentInstance.btnCaption = $localize`Ok`
|
||||||
// No local edits to preserve, so keep the tab in sync automatically.
|
modal.componentInstance.confirmClicked.subscribe(() =>
|
||||||
Object.assign(openDocument, doc)
|
modal.close()
|
||||||
openDocument.__changedFields = []
|
)
|
||||||
this.openDocumentService.setDirty(openDocument, false)
|
|
||||||
this.openDocumentService.save()
|
|
||||||
useDoc = openDocument
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.openDocumentService
|
this.openDocumentService
|
||||||
@@ -588,50 +522,6 @@ 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 {
|
ngOnInit(): void {
|
||||||
this.setZoom(
|
this.setZoom(
|
||||||
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
||||||
@@ -690,11 +580,6 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
this.getCustomFields()
|
this.getCustomFields()
|
||||||
|
|
||||||
this.websocketStatusService
|
|
||||||
.onDocumentUpdated()
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
|
|
||||||
|
|
||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(
|
filter(
|
||||||
@@ -1029,7 +914,6 @@ export class DocumentDetailComponent
|
|||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
this.closeIncomingUpdateModal()
|
|
||||||
Object.assign(this.document, doc)
|
Object.assign(this.document, doc)
|
||||||
doc['permissions_form'] = {
|
doc['permissions_form'] = {
|
||||||
owner: doc.owner,
|
owner: doc.owner,
|
||||||
@@ -1076,8 +960,6 @@ export class DocumentDetailComponent
|
|||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (docValues) => {
|
next: (docValues) => {
|
||||||
this.closeIncomingUpdateModal()
|
|
||||||
this.lastLocalSaveModified = docValues.modified ?? null
|
|
||||||
// in case data changed while saving eg removing inbox_tags
|
// in case data changed while saving eg removing inbox_tags
|
||||||
this.documentForm.patchValue(docValues)
|
this.documentForm.patchValue(docValues)
|
||||||
const newValues = Object.assign({}, this.documentForm.value)
|
const newValues = Object.assign({}, this.documentForm.value)
|
||||||
@@ -1092,19 +974,16 @@ export class DocumentDetailComponent
|
|||||||
this.networkActive = false
|
this.networkActive = false
|
||||||
this.error = null
|
this.error = null
|
||||||
if (close) {
|
if (close) {
|
||||||
this.pendingIncomingUpdate = null
|
|
||||||
this.close(() =>
|
this.close(() =>
|
||||||
this.openDocumentService.refreshDocument(this.documentId)
|
this.openDocumentService.refreshDocument(this.documentId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this.openDocumentService.refreshDocument(this.documentId)
|
this.openDocumentService.refreshDocument(this.documentId)
|
||||||
this.flushPendingIncomingUpdate()
|
|
||||||
}
|
}
|
||||||
this.savedViewService.maybeRefreshDocumentCounts()
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.networkActive = false
|
this.networkActive = false
|
||||||
this.lastLocalSaveModified = null
|
|
||||||
const canEdit =
|
const canEdit =
|
||||||
this.permissionsService.currentUserHasObjectPermissions(
|
this.permissionsService.currentUserHasObjectPermissions(
|
||||||
PermissionAction.Change,
|
PermissionAction.Change,
|
||||||
@@ -1124,7 +1003,6 @@ export class DocumentDetailComponent
|
|||||||
error
|
error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this.flushPendingIncomingUpdate()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1161,11 +1039,8 @@ export class DocumentDetailComponent
|
|||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: ({ updateResult, nextDocId, closeResult }) => {
|
next: ({ updateResult, nextDocId, closeResult }) => {
|
||||||
this.closeIncomingUpdateModal()
|
|
||||||
this.error = null
|
this.error = null
|
||||||
this.networkActive = false
|
this.networkActive = false
|
||||||
this.pendingIncomingUpdate = null
|
|
||||||
this.lastLocalSaveModified = null
|
|
||||||
if (closeResult && updateResult && nextDocId) {
|
if (closeResult && updateResult && nextDocId) {
|
||||||
this.router.navigate(['documents', nextDocId])
|
this.router.navigate(['documents', nextDocId])
|
||||||
this.titleInput?.focus()
|
this.titleInput?.focus()
|
||||||
@@ -1173,10 +1048,8 @@ export class DocumentDetailComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.networkActive = false
|
this.networkActive = false
|
||||||
this.lastLocalSaveModified = null
|
|
||||||
this.error = error.error
|
this.error = error.error
|
||||||
this.toastService.showError($localize`Error saving document`, error)
|
this.toastService.showError($localize`Error saving document`, error)
|
||||||
this.flushPendingIncomingUpdate()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1262,7 +1135,7 @@ export class DocumentDetailComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`Reprocess operation for "${this.document.title}" will begin in the background.`
|
$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.`
|
||||||
)
|
)
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.close()
|
modal.close()
|
||||||
|
|||||||
@@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions {
|
|||||||
checksum?: string
|
checksum?: string
|
||||||
|
|
||||||
// UTC
|
// UTC
|
||||||
created?: string // ISO string
|
created?: Date
|
||||||
|
|
||||||
modified?: string // ISO string
|
modified?: Date
|
||||||
|
|
||||||
added?: string // ISO string
|
added?: Date
|
||||||
|
|
||||||
mime_type?: string
|
mime_type?: string
|
||||||
|
|
||||||
deleted_at?: string // ISO string
|
deleted_at?: Date
|
||||||
|
|
||||||
original_file_name?: string
|
original_file_name?: string
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export interface WebsocketDocumentUpdatedMessage {
|
|
||||||
document_id: number
|
|
||||||
modified: string
|
|
||||||
owner_id?: number
|
|
||||||
users_can_view?: number[]
|
|
||||||
groups_can_view?: number[]
|
|
||||||
}
|
|
||||||
@@ -416,42 +416,4 @@ describe('ConsumerStatusService', () => {
|
|||||||
websocketStatusService.disconnect()
|
websocketStatusService.disconnect()
|
||||||
expect(deleted).toBeTruthy()
|
expect(deleted).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should trigger updated subject on document updated', () => {
|
|
||||||
let updated = false
|
|
||||||
websocketStatusService.onDocumentUpdated().subscribe((data) => {
|
|
||||||
updated = true
|
|
||||||
expect(data.document_id).toEqual(12)
|
|
||||||
})
|
|
||||||
|
|
||||||
websocketStatusService.connect()
|
|
||||||
server.send({
|
|
||||||
type: WebsocketStatusType.DOCUMENT_UPDATED,
|
|
||||||
data: {
|
|
||||||
document_id: 12,
|
|
||||||
modified: '2026-02-17T00:00:00Z',
|
|
||||||
owner_id: 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
websocketStatusService.disconnect()
|
|
||||||
expect(updated).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ignore document updated events the user cannot view', () => {
|
|
||||||
let updated = false
|
|
||||||
websocketStatusService.onDocumentUpdated().subscribe(() => {
|
|
||||||
updated = true
|
|
||||||
})
|
|
||||||
|
|
||||||
websocketStatusService.handleDocumentUpdated({
|
|
||||||
document_id: 12,
|
|
||||||
modified: '2026-02-17T00:00:00Z',
|
|
||||||
owner_id: 2,
|
|
||||||
users_can_view: [],
|
|
||||||
groups_can_view: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(updated).toBeFalsy()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable, inject } from '@angular/core'
|
|||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { User } from '../data/user'
|
import { User } from '../data/user'
|
||||||
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
|
||||||
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
||||||
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
|
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
@@ -10,7 +9,6 @@ import { SettingsService } from './settings.service'
|
|||||||
export enum WebsocketStatusType {
|
export enum WebsocketStatusType {
|
||||||
STATUS_UPDATE = 'status_update',
|
STATUS_UPDATE = 'status_update',
|
||||||
DOCUMENTS_DELETED = 'documents_deleted',
|
DOCUMENTS_DELETED = 'documents_deleted',
|
||||||
DOCUMENT_UPDATED = 'document_updated',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||||
@@ -95,20 +93,17 @@ export class FileStatus {
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class WebsocketStatusService {
|
export class WebsocketStatusService {
|
||||||
private readonly settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
|
|
||||||
private statusWebSocket: WebSocket
|
private statusWebSocket: WebSocket
|
||||||
|
|
||||||
private consumerStatus: FileStatus[] = []
|
private consumerStatus: FileStatus[] = []
|
||||||
|
|
||||||
private readonly documentDetectedSubject = new Subject<FileStatus>()
|
private documentDetectedSubject = new Subject<FileStatus>()
|
||||||
private readonly documentConsumptionFinishedSubject =
|
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
|
||||||
new Subject<FileStatus>()
|
private documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||||
private readonly documentConsumptionFailedSubject = new Subject<FileStatus>()
|
private documentDeletedSubject = new Subject<boolean>()
|
||||||
private readonly documentDeletedSubject = new Subject<boolean>()
|
private connectionStatusSubject = new Subject<boolean>()
|
||||||
private readonly documentUpdatedSubject =
|
|
||||||
new Subject<WebsocketDocumentUpdatedMessage>()
|
|
||||||
private readonly connectionStatusSubject = new Subject<boolean>()
|
|
||||||
|
|
||||||
private get(taskId: string, filename?: string) {
|
private get(taskId: string, filename?: string) {
|
||||||
let status =
|
let status =
|
||||||
@@ -174,10 +169,7 @@ export class WebsocketStatusService {
|
|||||||
data: messageData,
|
data: messageData,
|
||||||
}: {
|
}: {
|
||||||
type: WebsocketStatusType
|
type: WebsocketStatusType
|
||||||
data:
|
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
|
||||||
| WebsocketProgressMessage
|
|
||||||
| WebsocketDocumentsDeletedMessage
|
|
||||||
| WebsocketDocumentUpdatedMessage
|
|
||||||
} = JSON.parse(ev.data)
|
} = JSON.parse(ev.data)
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -185,12 +177,6 @@ export class WebsocketStatusService {
|
|||||||
this.documentDeletedSubject.next(true)
|
this.documentDeletedSubject.next(true)
|
||||||
break
|
break
|
||||||
|
|
||||||
case WebsocketStatusType.DOCUMENT_UPDATED:
|
|
||||||
this.handleDocumentUpdated(
|
|
||||||
messageData as WebsocketDocumentUpdatedMessage
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
case WebsocketStatusType.STATUS_UPDATE:
|
case WebsocketStatusType.STATUS_UPDATE:
|
||||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||||
break
|
break
|
||||||
@@ -198,11 +184,7 @@ export class WebsocketStatusService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private canViewMessage(messageData: {
|
private canViewMessage(messageData: WebsocketProgressMessage): boolean {
|
||||||
owner_id?: number
|
|
||||||
users_can_view?: number[]
|
|
||||||
groups_can_view?: number[]
|
|
||||||
}): boolean {
|
|
||||||
// see paperless.consumers.StatusConsumer._can_view
|
// see paperless.consumers.StatusConsumer._can_view
|
||||||
const user: User = this.settingsService.currentUser
|
const user: User = this.settingsService.currentUser
|
||||||
return (
|
return (
|
||||||
@@ -262,15 +244,6 @@ 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) {
|
fail(status: FileStatus, message: string) {
|
||||||
status.message = message
|
status.message = message
|
||||||
status.phase = FileStatusPhase.FAILED
|
status.phase = FileStatusPhase.FAILED
|
||||||
@@ -324,10 +297,6 @@ export class WebsocketStatusService {
|
|||||||
return this.documentDeletedSubject
|
return this.documentDeletedSubject
|
||||||
}
|
}
|
||||||
|
|
||||||
onDocumentUpdated() {
|
|
||||||
return this.documentUpdatedSubject
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnectionStatus() {
|
onConnectionStatus() {
|
||||||
return this.connectionStatusSubject.asObservable()
|
return this.connectionStatusSubject.asObservable()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ class DocumentsConfig(AppConfig):
|
|||||||
from documents.signals.handlers import add_to_index
|
from documents.signals.handlers import add_to_index
|
||||||
from documents.signals.handlers import run_workflows_added
|
from documents.signals.handlers import run_workflows_added
|
||||||
from documents.signals.handlers import run_workflows_updated
|
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_correspondent
|
||||||
from documents.signals.handlers import set_document_type
|
from documents.signals.handlers import set_document_type
|
||||||
from documents.signals.handlers import set_storage_path
|
from documents.signals.handlers import set_storage_path
|
||||||
@@ -30,7 +29,6 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(run_workflows_added)
|
document_consumption_finished.connect(run_workflows_added)
|
||||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||||
document_updated.connect(run_workflows_updated)
|
document_updated.connect(run_workflows_updated)
|
||||||
document_updated.connect(send_websocket_document_updated)
|
|
||||||
|
|
||||||
import documents.schema # noqa: F401
|
import documents.schema # noqa: F401
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import enum
|
import enum
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
@@ -48,7 +47,7 @@ class BaseStatusManager:
|
|||||||
async_to_sync(self._channel.flush)
|
async_to_sync(self._channel.flush)
|
||||||
self._channel = None
|
self._channel = None
|
||||||
|
|
||||||
def send(self, payload: Mapping[str, object]) -> None:
|
def send(self, payload: dict[str, str | int | None]) -> None:
|
||||||
# Ensure the layer is open
|
# Ensure the layer is open
|
||||||
self.open()
|
self.open()
|
||||||
|
|
||||||
@@ -74,28 +73,26 @@ class ProgressManager(BaseStatusManager):
|
|||||||
max_progress: int,
|
max_progress: int,
|
||||||
extra_args: dict[str, str | int | None] | None = None,
|
extra_args: dict[str, str | int | None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
data: dict[str, object] = {
|
payload = {
|
||||||
"filename": self.filename,
|
"type": "status_update",
|
||||||
"task_id": self.task_id,
|
"data": {
|
||||||
"current_progress": current_progress,
|
"filename": self.filename,
|
||||||
"max_progress": max_progress,
|
"task_id": self.task_id,
|
||||||
"status": status,
|
"current_progress": current_progress,
|
||||||
"message": message,
|
"max_progress": max_progress,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if extra_args is not None:
|
if extra_args is not None:
|
||||||
data.update(extra_args)
|
payload["data"].update(extra_args)
|
||||||
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"type": "status_update",
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.send(payload)
|
self.send(payload)
|
||||||
|
|
||||||
|
|
||||||
class DocumentsStatusManager(BaseStatusManager):
|
class DocumentsStatusManager(BaseStatusManager):
|
||||||
def send_documents_deleted(self, documents: list[int]) -> None:
|
def send_documents_deleted(self, documents: list[int]) -> None:
|
||||||
payload: dict[str, object] = {
|
payload = {
|
||||||
"type": "documents_deleted",
|
"type": "documents_deleted",
|
||||||
"data": {
|
"data": {
|
||||||
"documents": documents,
|
"documents": documents,
|
||||||
@@ -103,25 +100,3 @@ class DocumentsStatusManager(BaseStatusManager):
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.send(payload)
|
self.send(payload)
|
||||||
|
|
||||||
def send_document_updated(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
document_id: int,
|
|
||||||
modified: str,
|
|
||||||
owner_id: int | None = None,
|
|
||||||
users_can_view: list[int] | None = None,
|
|
||||||
groups_can_view: list[int] | None = None,
|
|
||||||
) -> None:
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"type": "document_updated",
|
|
||||||
"data": {
|
|
||||||
"document_id": document_id,
|
|
||||||
"modified": modified,
|
|
||||||
"owner_id": owner_id,
|
|
||||||
"users_can_view": users_can_view or [],
|
|
||||||
"groups_can_view": groups_can_view or [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
self.send(payload)
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from celery import states
|
from celery import states
|
||||||
@@ -24,7 +23,6 @@ from django.db.models import Q
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from documents import matching
|
from documents import matching
|
||||||
from documents.caching import clear_document_caches
|
from documents.caching import clear_document_caches
|
||||||
@@ -47,7 +45,6 @@ from documents.models import WorkflowAction
|
|||||||
from documents.models import WorkflowRun
|
from documents.models import WorkflowRun
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
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.templating.utils import convert_format_str_to_template_format
|
||||||
from documents.workflows.actions import build_workflow_action_context
|
from documents.workflows.actions import build_workflow_action_context
|
||||||
from documents.workflows.actions import execute_email_action
|
from documents.workflows.actions import execute_email_action
|
||||||
@@ -66,7 +63,6 @@ if TYPE_CHECKING:
|
|||||||
from documents.data_models import DocumentMetadataOverrides
|
from documents.data_models import DocumentMetadataOverrides
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.handlers")
|
logger = logging.getLogger("paperless.handlers")
|
||||||
DRF_DATETIME_FIELD = serializers.DateTimeField()
|
|
||||||
|
|
||||||
|
|
||||||
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
||||||
@@ -757,28 +753,6 @@ def run_workflows_updated(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_websocket_document_updated(
|
|
||||||
sender,
|
|
||||||
document: Document,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
# At this point, workflows may already have applied additional changes.
|
|
||||||
document.refresh_from_db()
|
|
||||||
|
|
||||||
from documents.data_models import DocumentMetadataOverrides
|
|
||||||
|
|
||||||
doc_overrides = DocumentMetadataOverrides.from_document(document)
|
|
||||||
|
|
||||||
with DocumentsStatusManager() as status_mgr:
|
|
||||||
status_mgr.send_document_updated(
|
|
||||||
document_id=document.id,
|
|
||||||
modified=DRF_DATETIME_FIELD.to_representation(document.modified),
|
|
||||||
owner_id=doc_overrides.owner_id,
|
|
||||||
users_can_view=doc_overrides.view_users,
|
|
||||||
groups_can_view=doc_overrides.view_groups,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_workflows(
|
def run_workflows(
|
||||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||||
document: Document | ConsumableDocument,
|
document: Document | ConsumableDocument,
|
||||||
@@ -1026,11 +1000,7 @@ def add_or_update_document_in_llm_index(sender, document, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_delete, sender=Document)
|
@receiver(models.signals.post_delete, sender=Document)
|
||||||
def delete_document_from_llm_index(
|
def delete_document_from_llm_index(sender, instance: Document, **kwargs):
|
||||||
sender: Any,
|
|
||||||
instance: Document,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Delete a document from the LLM index when it is deleted.
|
Delete a document from the LLM index when it is deleted.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ from documents.sanity_checker import SanityCheckFailedException
|
|||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
from documents.signals.handlers import cleanup_document_deletion
|
from documents.signals.handlers import cleanup_document_deletion
|
||||||
from documents.signals.handlers import run_workflows
|
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 documents.workflows.utils import get_workflows_for_trigger
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless_ai.indexing import llm_index_add_or_update_document
|
from paperless_ai.indexing import llm_index_add_or_update_document
|
||||||
@@ -535,11 +534,6 @@ def check_scheduled_workflows() -> None:
|
|||||||
workflow_to_run=workflow,
|
workflow_to_run=workflow,
|
||||||
document=document,
|
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:
|
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
|
||||||
|
|||||||
@@ -1206,11 +1206,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||||
|
|
||||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
||||||
self.assertTrue(
|
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
|
||||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
|
||||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.assertIsNone(overrides.title)
|
self.assertIsNone(overrides.title)
|
||||||
self.assertIsNone(overrides.correspondent_id)
|
self.assertIsNone(overrides.correspondent_id)
|
||||||
self.assertIsNone(overrides.document_type_id)
|
self.assertIsNone(overrides.document_type_id)
|
||||||
@@ -1259,11 +1255,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||||
|
|
||||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
||||||
self.assertTrue(
|
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
|
||||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
|
||||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.assertIsNone(overrides.title)
|
self.assertIsNone(overrides.title)
|
||||||
self.assertIsNone(overrides.correspondent_id)
|
self.assertIsNone(overrides.correspondent_id)
|
||||||
self.assertIsNone(overrides.document_type_id)
|
self.assertIsNone(overrides.document_type_id)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -641,9 +640,7 @@ class TestWorkflows(
|
|||||||
|
|
||||||
expected_str = f"Document did not match {w}"
|
expected_str = f"Document did not match {w}"
|
||||||
self.assertIn(expected_str, cm.output[0])
|
self.assertIn(expected_str, cm.output[0])
|
||||||
expected_str = (
|
expected_str = f"Document path {test_file} does not match"
|
||||||
f"Document path {Path(test_file).resolve(strict=False)} does not match"
|
|
||||||
)
|
|
||||||
self.assertIn(expected_str, cm.output[1])
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
def test_workflow_no_match_mail_rule(self) -> None:
|
def test_workflow_no_match_mail_rule(self) -> None:
|
||||||
@@ -1968,36 +1965,6 @@ class TestWorkflows(
|
|||||||
doc.refresh_from_db()
|
doc.refresh_from_db()
|
||||||
self.assertEqual(doc.owner, self.user2)
|
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:
|
def test_workflow_scheduled_trigger_added(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -4136,7 +4103,7 @@ class TestWebhookSecurity:
|
|||||||
def test_strips_user_supplied_host_header(
|
def test_strips_user_supplied_host_header(
|
||||||
self,
|
self,
|
||||||
httpx_mock: HTTPXMock,
|
httpx_mock: HTTPXMock,
|
||||||
resolve_to: Callable[[str], None],
|
resolve_to,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from channels.exceptions import AcceptConnection
|
from channels.exceptions import AcceptConnection
|
||||||
@@ -53,10 +52,3 @@ class StatusConsumer(WebsocketConsumer):
|
|||||||
self.close()
|
self.close()
|
||||||
else:
|
else:
|
||||||
self.send(json.dumps(event))
|
self.send(json.dumps(event))
|
||||||
|
|
||||||
def document_updated(self, event: Any) -> None:
|
|
||||||
if not self._authenticated():
|
|
||||||
self.close()
|
|
||||||
else:
|
|
||||||
if self._can_view(event["data"]):
|
|
||||||
self.send(json.dumps(event))
|
|
||||||
|
|||||||
@@ -48,20 +48,6 @@ class TestWebSockets(TestCase):
|
|||||||
mock_close.assert_called_once()
|
mock_close.assert_called_once()
|
||||||
mock_close.reset_mock()
|
mock_close.reset_mock()
|
||||||
|
|
||||||
message = {
|
|
||||||
"type": "document_updated",
|
|
||||||
"data": {"document_id": 10, "modified": "2026-02-17T00:00:00Z"},
|
|
||||||
}
|
|
||||||
|
|
||||||
await channel_layer.group_send(
|
|
||||||
"status_updates",
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
await communicator.receive_nothing()
|
|
||||||
|
|
||||||
mock_close.assert_called_once()
|
|
||||||
mock_close.reset_mock()
|
|
||||||
|
|
||||||
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
|
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
|
||||||
|
|
||||||
await channel_layer.group_send(
|
await channel_layer.group_send(
|
||||||
@@ -172,40 +158,6 @@ class TestWebSockets(TestCase):
|
|||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
@mock.patch("paperless.consumers.StatusConsumer._can_view")
|
|
||||||
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
|
|
||||||
async def test_receive_document_updated(self, _authenticated, _can_view) -> None:
|
|
||||||
_authenticated.return_value = True
|
|
||||||
_can_view.return_value = True
|
|
||||||
|
|
||||||
communicator = WebsocketCommunicator(application, "/ws/status/")
|
|
||||||
connected, _ = await communicator.connect()
|
|
||||||
self.assertTrue(connected)
|
|
||||||
|
|
||||||
message = {
|
|
||||||
"type": "document_updated",
|
|
||||||
"data": {
|
|
||||||
"document_id": 10,
|
|
||||||
"modified": "2026-02-17T00:00:00Z",
|
|
||||||
"owner_id": 1,
|
|
||||||
"users_can_view": [1],
|
|
||||||
"groups_can_view": [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
channel_layer = get_channel_layer()
|
|
||||||
assert channel_layer is not None
|
|
||||||
await channel_layer.group_send(
|
|
||||||
"status_updates",
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await communicator.receive_json_from()
|
|
||||||
|
|
||||||
self.assertEqual(response, message)
|
|
||||||
|
|
||||||
await communicator.disconnect()
|
|
||||||
|
|
||||||
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||||
def test_manager_send_progress(self, mock_group_send) -> None:
|
def test_manager_send_progress(self, mock_group_send) -> None:
|
||||||
with ProgressManager(task_id="test") as manager:
|
with ProgressManager(task_id="test") as manager:
|
||||||
@@ -238,10 +190,7 @@ class TestWebSockets(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||||
def test_manager_send_documents_deleted(
|
def test_manager_send_documents_deleted(self, mock_group_send) -> None:
|
||||||
self,
|
|
||||||
mock_group_send: mock.MagicMock,
|
|
||||||
) -> None:
|
|
||||||
with DocumentsStatusManager() as manager:
|
with DocumentsStatusManager() as manager:
|
||||||
manager.send_documents_deleted([1, 2, 3])
|
manager.send_documents_deleted([1, 2, 3])
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def get_embedding_model() -> BaseEmbedding:
|
|||||||
return OpenAIEmbedding(
|
return OpenAIEmbedding(
|
||||||
model=config.llm_embedding_model or "text-embedding-3-small",
|
model=config.llm_embedding_model or "text-embedding-3-small",
|
||||||
api_key=config.llm_api_key,
|
api_key=config.llm_api_key,
|
||||||
|
api_base=config.llm_endpoint or None,
|
||||||
)
|
)
|
||||||
case LLMEmbeddingBackend.HUGGINGFACE:
|
case LLMEmbeddingBackend.HUGGINGFACE:
|
||||||
return HuggingFaceEmbedding(
|
return HuggingFaceEmbedding(
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ def test_get_embedding_model_openai(mock_ai_config):
|
|||||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI
|
||||||
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
|
||||||
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
mock_ai_config.return_value.llm_api_key = "test_api_key"
|
||||||
|
mock_ai_config.return_value.llm_endpoint = "http://test-url"
|
||||||
|
|
||||||
with patch("paperless_ai.embedding.OpenAIEmbedding") as MockOpenAIEmbedding:
|
with patch("paperless_ai.embedding.OpenAIEmbedding") as MockOpenAIEmbedding:
|
||||||
model = get_embedding_model()
|
model = get_embedding_model()
|
||||||
MockOpenAIEmbedding.assert_called_once_with(
|
MockOpenAIEmbedding.assert_called_once_with(
|
||||||
model="text-embedding-3-small",
|
model="text-embedding-3-small",
|
||||||
api_key="test_api_key",
|
api_key="test_api_key",
|
||||||
|
api_base="http://test-url",
|
||||||
)
|
)
|
||||||
assert model == MockOpenAIEmbedding.return_value
|
assert model == MockOpenAIEmbedding.return_value
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user