Compare commits

..

2 Commits

Author SHA1 Message Date
Trenton H
57ea7a716b Chore: convert test_adapter.py to pytest style 2026-03-03 13:45:01 -08:00
Trenton H
ba7b538398 Chore: convert paperless unit tests to pytest style 2026-03-03 13:27:20 -08:00
25 changed files with 377 additions and 970 deletions

View File

@@ -440,6 +440,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: Missing type parameters for generic type "QuerySet" [type-arg]
src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg]
src/documents/plugins/helpers.py:0: error: "Collection[str]" has no attribute "update" [attr-defined]
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped]
@@ -664,6 +667,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: Function is missing a return type annotation [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
@@ -1924,7 +1928,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: 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]

View File

@@ -310,7 +310,6 @@ markers = [
[tool.pytest_env]
PAPERLESS_DISABLE_DBHANDLER = "true"
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer"
[tool.coverage.report]
exclude_also = [

View File

@@ -1238,8 +1238,8 @@
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="7860582931776068318" datatype="html">
<source>Add document version</source>
<trans-unit id="8035757452478567832" datatype="html">
<source>Update existing document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
<context context-type="linenumber">280</context>
@@ -8411,8 +8411,8 @@
<context context-type="linenumber">832</context>
</context-group>
</trans-unit>
<trans-unit id="5203024009814367559" datatype="html">
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
<trans-unit id="6390006284731990222" datatype="html">
<source>This operation will permanently rotate the original version of <x id="PH" equiv-text="this.list.selected.size"/> document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">833</context>

View File

@@ -277,7 +277,7 @@
<div class="col">
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Update existing document</option>
</select>
</div>
</div>

View File

@@ -84,7 +84,7 @@
<input type="radio" class="btn-check" [(ngModel)]="editMode" [value]="PdfEditorEditMode.Update" id="editModeUpdate" name="editmode" [disabled]="hasSplit()">
<label for="editModeUpdate" class="btn btn-outline-primary btn-sm">
<i-bs name="pencil"></i-bs>
<span class="form-check-label ms-2" i18n>Add document version</span>
<span class="form-check-label ms-2" i18n>Update existing document</span>
</label>
</div>
@if (editMode === PdfEditorEditMode.Create) {

View File

@@ -65,7 +65,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
@@ -84,9 +83,9 @@ const doc: Document = {
storage_path: 31,
tags: [41, 42, 43],
content: 'text content',
added: new Date('May 4, 2014 03:24:00').toISOString(),
created: new Date('May 4, 2014 03:24:00').toISOString(),
modified: 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'),
modified: new Date('May 4, 2014 03:24:00'),
archive_serial_number: null,
original_file_name: 'file.pdf',
owner: null,
@@ -328,29 +327,6 @@ describe('DocumentDetailComponent', () => {
expect(component.activeNavID).toEqual(component.DocumentDetailNavIDs.Notes)
})
it('should switch from preview to details when pdf preview enters the DOM', fakeAsync(() => {
component.nav = {
activeId: component.DocumentDetailNavIDs.Preview,
select: jest.fn(),
} as any
;(component as any).pdfPreview = {
nativeElement: { offsetParent: {} },
}
tick()
expect(component.nav.select).toHaveBeenCalledWith(
component.DocumentDetailNavIDs.Details
)
}))
it('should forward title key up value to titleSubject', () => {
const subjectSpy = jest.spyOn(component.titleSubject, 'next')
component.titleKeyUp({ target: { value: 'Updated title' } })
expect(subjectSpy).toHaveBeenCalledWith('Updated title')
})
it('should change url on tab switch', () => {
initNormally()
const navigateSpy = jest.spyOn(router, 'navigate')
@@ -548,7 +524,7 @@ describe('DocumentDetailComponent', () => {
jest.spyOn(documentService, 'get').mockReturnValue(
of({
...doc,
modified: '2024-01-02T00:00:00Z',
modified: new Date('2024-01-02T00:00:00Z'),
duplicate_documents: updatedDuplicates,
})
)
@@ -1410,21 +1386,17 @@ describe('DocumentDetailComponent', () => {
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
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
const modalSpy = jest.spyOn(modalService, 'open')
const openDoc = Object.assign({}, doc, {
__changedFields: ['title'],
})
const openDoc = Object.assign({}, doc)
// simulate a document being modified elsewhere and db updated
const remoteDoc = Object.assign({}, doc, {
modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
})
doc.modified = new Date()
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.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(customFieldsService, 'listAll').mockReturnValue(
of({
@@ -1434,185 +1406,11 @@ describe('DocumentDetailComponent', () => {
})
)
fixture.detectChanges() // calls ngOnInit
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, {
backdrop: 'static',
})
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
const closeSpy = jest.spyOn(openModal, 'close')
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
expect(confirmDialog.messageBold).toContain('Document was updated at')
})
it('should react to websocket document updated notifications', () => {
initNormally()
const updateMessage = {
document_id: component.documentId,
modified: '2026-02-17T00:00:00Z',
owner_id: 1,
}
const handleSpy = jest
.spyOn(component as any, 'handleIncomingDocumentUpdated')
.mockImplementation(() => {})
const websocketStatusService = TestBed.inject(WebsocketStatusService)
websocketStatusService.handleDocumentUpdated(updateMessage)
expect(handleSpy).toHaveBeenCalledWith(updateMessage)
})
it('should queue incoming update while network is active and flush after', () => {
initNormally()
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.networkActive = true
;(component as any).handleIncomingDocumentUpdated({
document_id: component.documentId,
modified: '2026-02-17T00:00:00Z',
})
expect(loadSpy).not.toHaveBeenCalled()
component.networkActive = false
;(component as any).flushPendingIncomingUpdate()
expect(loadSpy).toHaveBeenCalledWith(component.documentId, true)
expect(toastSpy).toHaveBeenCalledWith(
'Document reloaded with latest changes.'
)
})
it('should ignore queued incoming update matching local save modified', () => {
initNormally()
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.networkActive = true
;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00'
;(component as any).handleIncomingDocumentUpdated({
document_id: component.documentId,
modified: '2026-02-17T00:00:00+00:00',
})
component.networkActive = false
;(component as any).flushPendingIncomingUpdate()
expect(loadSpy).not.toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
})
it('should clear pdf source if preview URL is empty', () => {
component.pdfSource = { url: '/preview', password: 'secret' } as any
component.previewUrl = null
;(component as any).updatePdfSource()
expect(component.pdfSource).toEqual({ url: null, password: undefined })
})
it('should close incoming update modal if one is open', () => {
const modalRef = { close: jest.fn() } as unknown as NgbModalRef
;(component as any).incomingUpdateModal = modalRef
;(component as any).closeIncomingUpdateModal()
expect(modalRef.close).toHaveBeenCalled()
expect((component as any).incomingUpdateModal).toBeNull()
})
it('should reload remote version when incoming update modal is confirmed', async () => {
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
const reloadSpy = jest
.spyOn(component as any, 'reloadRemoteVersion')
.mockImplementation(() => {})
;(component as any).showIncomingUpdateModal('2026-02-17T00:00:00Z')
const dialog = openModal.componentInstance as ConfirmDialogComponent
dialog.confirmClicked.next()
await openModal.result
expect(dialog.buttonsEnabled).toBe(false)
expect(reloadSpy).toHaveBeenCalled()
expect((component as any).incomingUpdateModal).toBeNull()
})
it('should overwrite open document state when loading remote version with force', () => {
const openDoc = Object.assign({}, doc, {
title: 'Locally edited title',
__changedFields: ['title'],
})
const remoteDoc = Object.assign({}, doc, {
title: 'Remote title',
modified: '2026-02-17T00:00:00Z',
})
jest.spyOn(documentService, 'get').mockReturnValue(of(remoteDoc))
jest.spyOn(documentService, 'getMetadata').mockReturnValue(
of({
has_archive_version: false,
original_mime_type: 'application/pdf',
})
)
jest.spyOn(documentService, 'getSuggestions').mockReturnValue(
of({
suggested_tags: [],
suggested_document_types: [],
suggested_correspondents: [],
})
)
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
const setDirtySpy = jest.spyOn(openDocumentsService, 'setDirty')
const saveSpy = jest.spyOn(openDocumentsService, 'save')
;(component as any).loadDocument(doc.id, true)
expect(openDoc.title).toEqual('Remote title')
expect(openDoc.__changedFields).toEqual([])
expect(setDirtySpy).toHaveBeenCalledWith(openDoc, false)
expect(saveSpy).toHaveBeenCalled()
})
it('should ignore incoming update for a different document id', () => {
initNormally()
const loadSpy = jest.spyOn(component as any, 'loadDocument')
;(component as any).handleIncomingDocumentUpdated({
document_id: component.documentId + 1,
modified: '2026-02-17T00:00:00Z',
})
expect(loadSpy).not.toHaveBeenCalled()
})
it('should show incoming update modal when local document has unsaved edits', () => {
initNormally()
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
const modalSpy = jest
.spyOn(component as any, 'showIncomingUpdateModal')
.mockImplementation(() => {})
;(component as any).handleIncomingDocumentUpdated({
document_id: component.documentId,
modified: '2026-02-17T00:00:00Z',
})
expect(modalSpy).toHaveBeenCalledWith('2026-02-17T00:00:00Z')
})
it('should reload current document and show toast when reloading remote version', () => {
component.documentId = doc.id
const closeModalSpy = jest
.spyOn(component as any, 'closeIncomingUpdateModal')
.mockImplementation(() => {})
const loadSpy = jest
.spyOn(component as any, 'loadDocument')
.mockImplementation(() => {})
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
const toastSpy = jest.spyOn(toastService, 'showInfo')
;(component as any).reloadRemoteVersion()
expect(closeModalSpy).toHaveBeenCalled()
expect(notifySpy).toHaveBeenCalledWith(doc.id)
expect(loadSpy).toHaveBeenCalledWith(doc.id, true)
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
confirmDialog.confirmClicked.next(confirmDialog)
expect(closeSpy).toHaveBeenCalled()
})
it('should change preview element by render type', () => {
@@ -1923,14 +1721,6 @@ describe('DocumentDetailComponent', () => {
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
})
it('should expose add permission via userCanAdd getter', () => {
currentUserCan = true
expect(component.userCanAdd).toBeTruthy()
currentUserCan = false
expect(component.userCanAdd).toBeFalsy()
})
it('should call tryRenderTiff when no archive and file is tiff', () => {
initNormally()
const tiffRenderSpy = jest.spyOn(

View File

@@ -13,7 +13,6 @@ import {
NgbDateStruct,
NgbDropdownModule,
NgbModal,
NgbModalRef,
NgbNav,
NgbNavChangeEvent,
NgbNavModule,
@@ -81,7 +80,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif'
@@ -145,11 +143,6 @@ enum ContentRenderType {
TIFF = 'tiff',
}
interface IncomingDocumentUpdate {
document_id: number
modified: string
}
@Component({
selector: 'pngx-document-detail',
templateUrl: './document-detail.component.html',
@@ -215,7 +208,6 @@ export class DocumentDetailComponent
private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService)
private readonly websocketStatusService = inject(WebsocketStatusService)
@ViewChild('inputTitle')
titleInput: TextComponent
@@ -275,9 +267,6 @@ export class DocumentDetailComponent
isDirty$: Observable<boolean>
unsubscribeNotifier: Subject<any> = new Subject()
docChangeNotifier: Subject<any> = new Subject()
private incomingUpdateModal: NgbModalRef
private pendingIncomingUpdate: IncomingDocumentUpdate
private lastLocalSaveModified: string | null = null
requiresPassword: boolean = false
password: string
@@ -486,12 +475,9 @@ export class DocumentDetailComponent
)
}
private loadDocument(documentId: number, forceRemote: boolean = false): void {
private loadDocument(documentId: number): void {
let redirectedToRoot = false
this.closeIncomingUpdateModal()
this.pendingIncomingUpdate = null
this.selectedVersionId = documentId
this.lastLocalSaveModified = null
this.previewUrl = this.documentsService.getPreviewUrl(
this.selectedVersionId
)
@@ -559,25 +545,21 @@ export class DocumentDetailComponent
openDocument.duplicate_documents = doc.duplicate_documents
this.openDocumentService.save()
}
let useDoc = openDocument || doc
if (openDocument && forceRemote) {
Object.assign(openDocument, doc)
openDocument.__changedFields = []
this.openDocumentService.setDirty(openDocument, false)
this.openDocumentService.save()
useDoc = openDocument
} else if (openDocument) {
if (new Date(doc.modified) > new Date(openDocument.modified)) {
if (this.hasLocalEdits(openDocument)) {
this.showIncomingUpdateModal(doc.modified)
} else {
// No local edits to preserve, so keep the tab in sync automatically.
Object.assign(openDocument, doc)
openDocument.__changedFields = []
this.openDocumentService.setDirty(openDocument, false)
this.openDocumentService.save()
useDoc = openDocument
}
const useDoc = openDocument || doc
if (openDocument) {
if (
new Date(doc.modified) > new Date(openDocument.modified) &&
!this.modalService.hasOpenModals()
) {
const modal = this.modalService.open(ConfirmDialogComponent)
modal.componentInstance.title = $localize`Document changes detected`
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
modal.componentInstance.cancelBtnClass = 'visually-hidden'
modal.componentInstance.btnCaption = $localize`Ok`
modal.componentInstance.confirmClicked.subscribe(() =>
modal.close()
)
}
} else {
this.openDocumentService
@@ -608,98 +590,6 @@ export class DocumentDetailComponent
})
}
private hasLocalEdits(doc: Document): boolean {
return (
this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length
)
}
private showIncomingUpdateModal(modified: string): void {
if (this.incomingUpdateModal) return
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
this.incomingUpdateModal = modal
let formattedModified = null
const parsed = new Date(modified)
formattedModified = parsed.toLocaleString()
modal.componentInstance.title = $localize`Document was updated`
modal.componentInstance.messageBold = $localize`Document was updated at ${formattedModified}.`
modal.componentInstance.message = $localize`Reload to discard your local unsaved edits and load the latest remote version.`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Reload`
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled = false
modal.close()
this.reloadRemoteVersion()
})
modal.result.finally(() => {
this.incomingUpdateModal = null
})
}
private closeIncomingUpdateModal() {
if (!this.incomingUpdateModal) return
this.incomingUpdateModal.close()
this.incomingUpdateModal = null
}
private flushPendingIncomingUpdate() {
if (!this.pendingIncomingUpdate || this.networkActive) return
const pendingUpdate = this.pendingIncomingUpdate
this.pendingIncomingUpdate = null
this.handleIncomingDocumentUpdated(pendingUpdate)
}
private handleIncomingDocumentUpdated(data: IncomingDocumentUpdate): void {
if (
!this.documentId ||
!this.document ||
data.document_id !== this.documentId
)
return
if (this.networkActive) {
this.pendingIncomingUpdate = data
return
}
// If modified timestamp of the incoming update is the same as the last local save,
// we assume this update is from our own save and dont notify
const incomingModified = data.modified
if (
incomingModified &&
this.lastLocalSaveModified &&
incomingModified === this.lastLocalSaveModified
) {
this.lastLocalSaveModified = null
return
}
this.lastLocalSaveModified = null
if (this.openDocumentService.isDirty(this.document)) {
this.showIncomingUpdateModal(data.modified)
} else {
this.docChangeNotifier.next(this.documentId)
this.loadDocument(this.documentId, true)
this.toastService.showInfo(
$localize`Document reloaded with latest changes.`
)
}
}
private reloadRemoteVersion() {
if (!this.documentId) return
this.closeIncomingUpdateModal()
this.docChangeNotifier.next(this.documentId)
this.loadDocument(this.documentId, true)
this.toastService.showInfo($localize`Document reloaded.`)
}
ngOnInit(): void {
this.setZoom(
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
@@ -758,11 +648,6 @@ export class DocumentDetailComponent
this.getCustomFields()
this.websocketStatusService
.onDocumentUpdated()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
this.route.paramMap
.pipe(
filter(
@@ -1148,7 +1033,6 @@ export class DocumentDetailComponent
)
.subscribe({
next: (doc) => {
this.closeIncomingUpdateModal()
Object.assign(this.document, doc)
doc['permissions_form'] = {
owner: doc.owner,
@@ -1195,8 +1079,6 @@ export class DocumentDetailComponent
.pipe(first())
.subscribe({
next: (docValues) => {
this.closeIncomingUpdateModal()
this.lastLocalSaveModified = docValues.modified ?? null
// in case data changed while saving eg removing inbox_tags
this.documentForm.patchValue(docValues)
const newValues = Object.assign({}, this.documentForm.value)
@@ -1211,19 +1093,16 @@ export class DocumentDetailComponent
this.networkActive = false
this.error = null
if (close) {
this.pendingIncomingUpdate = null
this.close(() =>
this.openDocumentService.refreshDocument(this.documentId)
)
} else {
this.openDocumentService.refreshDocument(this.documentId)
this.flushPendingIncomingUpdate()
}
this.savedViewService.maybeRefreshDocumentCounts()
},
error: (error) => {
this.networkActive = false
this.lastLocalSaveModified = null
const canEdit =
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
@@ -1243,7 +1122,6 @@ export class DocumentDetailComponent
error
)
}
this.flushPendingIncomingUpdate()
},
})
}
@@ -1280,11 +1158,8 @@ export class DocumentDetailComponent
.pipe(first())
.subscribe({
next: ({ updateResult, nextDocId, closeResult }) => {
this.closeIncomingUpdateModal()
this.error = null
this.networkActive = false
this.pendingIncomingUpdate = null
this.lastLocalSaveModified = null
if (closeResult && updateResult && nextDocId) {
this.router.navigate(['documents', nextDocId])
this.titleInput?.focus()
@@ -1292,10 +1167,8 @@ export class DocumentDetailComponent
},
error: (error) => {
this.networkActive = false
this.lastLocalSaveModified = null
this.error = error.error
this.toastService.showError($localize`Error saving document`, error)
this.flushPendingIncomingUpdate()
},
})
}
@@ -1381,7 +1254,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
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) {
modal.close()

View File

@@ -830,7 +830,7 @@ export class BulkEditorComponent
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will add rotated versions of the ${this.list.selected.size} document(s).`
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]

View File

@@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions {
checksum?: string
// UTC
created?: string // ISO string
created?: Date
modified?: string // ISO string
modified?: Date
added?: string // ISO string
added?: Date
mime_type?: string
deleted_at?: string // ISO string
deleted_at?: Date
original_file_name?: string

View File

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

View File

@@ -416,42 +416,4 @@ describe('ConsumerStatusService', () => {
websocketStatusService.disconnect()
expect(deleted).toBeTruthy()
})
it('should trigger updated subject on document updated', () => {
let updated = false
websocketStatusService.onDocumentUpdated().subscribe((data) => {
updated = true
expect(data.document_id).toEqual(12)
})
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.DOCUMENT_UPDATED,
data: {
document_id: 12,
modified: '2026-02-17T00:00:00Z',
owner_id: 1,
},
})
websocketStatusService.disconnect()
expect(updated).toBeTruthy()
})
it('should ignore document updated events the user cannot view', () => {
let updated = false
websocketStatusService.onDocumentUpdated().subscribe(() => {
updated = true
})
websocketStatusService.handleDocumentUpdated({
document_id: 12,
modified: '2026-02-17T00:00:00Z',
owner_id: 2,
users_can_view: [],
groups_can_view: [],
})
expect(updated).toBeFalsy()
})
})

View File

@@ -2,7 +2,6 @@ import { Injectable, inject } from '@angular/core'
import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment'
import { User } from '../data/user'
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
import { SettingsService } from './settings.service'
@@ -10,7 +9,6 @@ import { SettingsService } from './settings.service'
export enum WebsocketStatusType {
STATUS_UPDATE = 'status_update',
DOCUMENTS_DELETED = 'documents_deleted',
DOCUMENT_UPDATED = 'document_updated',
}
// see ProgressStatusOptions in src/documents/plugins/helpers.py
@@ -102,20 +100,17 @@ export enum UploadState {
providedIn: 'root',
})
export class WebsocketStatusService {
private readonly settingsService = inject(SettingsService)
private settingsService = inject(SettingsService)
private statusWebSocket: WebSocket
private consumerStatus: FileStatus[] = []
private readonly documentDetectedSubject = new Subject<FileStatus>()
private readonly documentConsumptionFinishedSubject =
new Subject<FileStatus>()
private readonly documentConsumptionFailedSubject = new Subject<FileStatus>()
private readonly documentDeletedSubject = new Subject<boolean>()
private readonly documentUpdatedSubject =
new Subject<WebsocketDocumentUpdatedMessage>()
private readonly connectionStatusSubject = new Subject<boolean>()
private documentDetectedSubject = new Subject<FileStatus>()
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
private documentConsumptionFailedSubject = new Subject<FileStatus>()
private documentDeletedSubject = new Subject<boolean>()
private connectionStatusSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) {
let status =
@@ -181,10 +176,7 @@ export class WebsocketStatusService {
data: messageData,
}: {
type: WebsocketStatusType
data:
| WebsocketProgressMessage
| WebsocketDocumentsDeletedMessage
| WebsocketDocumentUpdatedMessage
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
} = JSON.parse(ev.data)
switch (type) {
@@ -192,12 +184,6 @@ export class WebsocketStatusService {
this.documentDeletedSubject.next(true)
break
case WebsocketStatusType.DOCUMENT_UPDATED:
this.handleDocumentUpdated(
messageData as WebsocketDocumentUpdatedMessage
)
break
case WebsocketStatusType.STATUS_UPDATE:
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break
@@ -205,11 +191,7 @@ export class WebsocketStatusService {
}
}
private canViewMessage(messageData: {
owner_id?: number
users_can_view?: number[]
groups_can_view?: number[]
}): boolean {
private canViewMessage(messageData: WebsocketProgressMessage): boolean {
// see paperless.consumers.StatusConsumer._can_view
const user: User = this.settingsService.currentUser
return (
@@ -269,15 +251,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) {
status.message = message
status.phase = FileStatusPhase.FAILED
@@ -331,10 +304,6 @@ export class WebsocketStatusService {
return this.documentDeletedSubject
}
onDocumentUpdated() {
return this.documentUpdatedSubject
}
onConnectionStatus() {
return this.connectionStatusSubject.asObservable()
}

View File

@@ -15,7 +15,6 @@ class DocumentsConfig(AppConfig):
from documents.signals.handlers import add_to_index
from documents.signals.handlers import run_workflows_added
from documents.signals.handlers import run_workflows_updated
from documents.signals.handlers import send_websocket_document_updated
from documents.signals.handlers import set_correspondent
from documents.signals.handlers import set_document_type
from documents.signals.handlers import set_storage_path
@@ -30,7 +29,6 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(run_workflows_added)
document_consumption_finished.connect(add_or_update_document_in_llm_index)
document_updated.connect(run_workflows_updated)
document_updated.connect(send_websocket_document_updated)
import documents.schema # noqa: F401

View File

@@ -1,5 +1,4 @@
import enum
from collections.abc import Mapping
from typing import TYPE_CHECKING
from asgiref.sync import async_to_sync
@@ -48,7 +47,7 @@ class BaseStatusManager:
async_to_sync(self._channel.flush)
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
self.open()
@@ -74,28 +73,26 @@ class ProgressManager(BaseStatusManager):
max_progress: int,
extra_args: dict[str, str | int | None] | None = None,
) -> None:
data: dict[str, object] = {
"filename": self.filename,
"task_id": self.task_id,
"current_progress": current_progress,
"max_progress": max_progress,
"status": status,
"message": message,
payload = {
"type": "status_update",
"data": {
"filename": self.filename,
"task_id": self.task_id,
"current_progress": current_progress,
"max_progress": max_progress,
"status": status,
"message": message,
},
}
if extra_args is not None:
data.update(extra_args)
payload: dict[str, object] = {
"type": "status_update",
"data": data,
}
payload["data"].update(extra_args)
self.send(payload)
class DocumentsStatusManager(BaseStatusManager):
def send_documents_deleted(self, documents: list[int]) -> None:
payload: dict[str, object] = {
payload = {
"type": "documents_deleted",
"data": {
"documents": documents,
@@ -103,25 +100,3 @@ class DocumentsStatusManager(BaseStatusManager):
}
self.send(payload)
def send_document_updated(
self,
*,
document_id: int,
modified: str,
owner_id: int | None = None,
users_can_view: list[int] | None = None,
groups_can_view: list[int] | None = None,
) -> None:
payload: dict[str, object] = {
"type": "document_updated",
"data": {
"document_id": document_id,
"modified": modified,
"owner_id": owner_id,
"users_can_view": users_can_view or [],
"groups_can_view": groups_can_view or [],
},
}
self.send(payload)

View File

@@ -24,7 +24,6 @@ from django.db.models import Q
from django.dispatch import receiver
from django.utils import timezone
from filelock import FileLock
from rest_framework import serializers
from documents import matching
from documents.caching import clear_document_caches
@@ -49,7 +48,6 @@ from documents.models import WorkflowAction
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware
from documents.plugins.helpers import DocumentsStatusManager
from documents.templating.utils import convert_format_str_to_template_format
from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action
@@ -71,7 +69,6 @@ if TYPE_CHECKING:
from documents.data_models import DocumentMetadataOverrides
logger = logging.getLogger("paperless.handlers")
DRF_DATETIME_FIELD = serializers.DateTimeField()
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
@@ -767,28 +764,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(
trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document | ConsumableDocument,
@@ -1070,11 +1045,7 @@ def add_or_update_document_in_llm_index(sender, document, **kwargs):
@receiver(models.signals.post_delete, sender=Document)
def delete_document_from_llm_index(
sender: Any,
instance: Document,
**kwargs: Any,
) -> None:
def delete_document_from_llm_index(sender, instance: Document, **kwargs):
"""
Delete a document from the LLM index when it is deleted.
"""

View File

@@ -62,7 +62,6 @@ from documents.sanity_checker import SanityCheckFailedException
from documents.signals import document_updated
from documents.signals.handlers import cleanup_document_deletion
from documents.signals.handlers import run_workflows
from documents.signals.handlers import send_websocket_document_updated
from documents.workflows.utils import get_workflows_for_trigger
from paperless.config import AIConfig
from paperless_ai.indexing import llm_index_add_or_update_document
@@ -573,11 +572,6 @@ def check_scheduled_workflows() -> None:
workflow_to_run=workflow,
document=document,
)
# Scheduled workflows dont send document_updated signal, so send a websocket update here to ensure clients are updated
send_websocket_document_updated(
sender=None,
document=document,
)
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:

View File

@@ -1270,11 +1270,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
input_doc, overrides = self.get_last_consume_delay_call_args()
self.assertEqual(input_doc.original_file.name, "simple.pdf")
self.assertTrue(
input_doc.original_file.resolve(strict=False).is_relative_to(
Path(settings.SCRATCH_DIR).resolve(strict=False),
),
)
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
self.assertIsNone(overrides.title)
self.assertIsNone(overrides.correspondent_id)
self.assertIsNone(overrides.document_type_id)
@@ -1355,11 +1351,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
input_doc, overrides = self.get_last_consume_delay_call_args()
self.assertEqual(input_doc.original_file.name, "simple.pdf")
self.assertTrue(
input_doc.original_file.resolve(strict=False).is_relative_to(
Path(settings.SCRATCH_DIR).resolve(strict=False),
),
)
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
self.assertIsNone(overrides.title)
self.assertIsNone(overrides.correspondent_id)
self.assertIsNone(overrides.document_type_id)

View File

@@ -643,9 +643,7 @@ class TestWorkflows(
expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0])
expected_str = (
f"Document path {Path(test_file).resolve(strict=False)} does not match"
)
expected_str = f"Document path {test_file} does not match"
self.assertIn(expected_str, cm.output[1])
def test_workflow_no_match_mail_rule(self) -> None:
@@ -2012,36 +2010,6 @@ class TestWorkflows(
doc.refresh_from_db()
self.assertEqual(doc.owner, self.user2)
@mock.patch("documents.tasks.send_websocket_document_updated")
def test_workflow_scheduled_trigger_sends_websocket_update(
self,
mock_send_websocket_document_updated,
) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
schedule_offset_days=1,
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
)
action = WorkflowAction.objects.create(assign_owner=self.user2)
workflow = Workflow.objects.create(name="Workflow 1", order=0)
workflow.triggers.add(trigger)
workflow.actions.add(action)
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
created=timezone.now() - timedelta(days=2),
)
tasks.check_scheduled_workflows()
self.assertEqual(mock_send_websocket_document_updated.call_count, 1)
self.assertEqual(
mock_send_websocket_document_updated.call_args.kwargs["document"].pk,
doc.pk,
)
def test_workflow_scheduled_trigger_added(self) -> None:
"""
GIVEN:

View File

@@ -1,5 +1,4 @@
import json
from typing import Any
from asgiref.sync import async_to_sync
from channels.exceptions import AcceptConnection
@@ -53,10 +52,3 @@ class StatusConsumer(WebsocketConsumer):
self.close()
else:
self.send(json.dumps(event))
def document_updated(self, event: Any) -> None:
if not self._authenticated():
self.close()
else:
if self._can_view(event["data"]):
self.send(json.dumps(event))

View File

@@ -493,24 +493,18 @@ TEMPLATES = [
},
]
_CHANNELS_BACKEND = os.environ.get(
"PAPERLESS_CHANNELS_BACKEND",
"channels_redis.pubsub.RedisPubSubChannelLayer",
)
CHANNEL_LAYERS = {
"default": {
"BACKEND": _CHANNELS_BACKEND,
"BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer",
"CONFIG": {
"hosts": [_CHANNELS_REDIS_URL],
"capacity": 2000, # default 100
"expiry": 15, # default 60
"prefix": _REDIS_KEY_PREFIX,
},
},
}
if _CHANNELS_BACKEND.startswith("channels_redis."):
CHANNEL_LAYERS["default"]["CONFIG"] = {
"hosts": [_CHANNELS_REDIS_URL],
"capacity": 2000, # default 100
"expiry": 15, # default 60
"prefix": _REDIS_KEY_PREFIX,
}
###############################################################################
# Email (SMTP) Backend #
###############################################################################

View File

@@ -1,107 +1,100 @@
from unittest import mock
import logging
import pytest
from allauth.account.adapter import get_adapter
from allauth.core import context
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.forms import ValidationError
from django.http import HttpRequest
from django.test import TestCase
from django.test import override_settings
from django.urls import reverse
from pytest_django.fixtures import SettingsWrapper
from pytest_mock import MockerFixture
from rest_framework.authtoken.models import Token
from paperless.adapter import DrfTokenStrategy
class TestCustomAccountAdapter(TestCase):
def test_is_open_for_signup(self) -> None:
@pytest.mark.django_db
class TestCustomAccountAdapter:
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
adapter = get_adapter()
# With no accounts, signups should be allowed
self.assertTrue(adapter.is_open_for_signup(None))
assert adapter.is_open_for_signup(None)
User.objects.create_user("testuser")
# Test when ACCOUNT_ALLOW_SIGNUPS is True
settings.ACCOUNT_ALLOW_SIGNUPS = True
self.assertTrue(adapter.is_open_for_signup(None))
assert adapter.is_open_for_signup(None)
# Test when ACCOUNT_ALLOW_SIGNUPS is False
settings.ACCOUNT_ALLOW_SIGNUPS = False
self.assertFalse(adapter.is_open_for_signup(None))
assert not adapter.is_open_for_signup(None)
def test_is_safe_url(self) -> None:
def test_is_safe_url(self, settings: SettingsWrapper) -> None:
request = HttpRequest()
request.get_host = mock.Mock(return_value="example.com")
request.get_host = lambda: "example.com"
with context.request_context(request):
adapter = get_adapter()
with override_settings(ALLOWED_HOSTS=["*"]):
# True because request host is same
url = "https://example.com"
self.assertTrue(adapter.is_safe_url(url))
url = "https://evil.com"
settings.ALLOWED_HOSTS = ["*"]
# True because request host is same
assert adapter.is_safe_url("https://example.com")
# False despite wildcard because request host is different
self.assertFalse(adapter.is_safe_url(url))
assert not adapter.is_safe_url("https://evil.com")
settings.ALLOWED_HOSTS = ["example.com"]
url = "https://example.com"
# True because request host is same
self.assertTrue(adapter.is_safe_url(url))
assert adapter.is_safe_url("https://example.com")
settings.ALLOWED_HOSTS = ["*", "example.com"]
url = "//evil.com"
# False because request host is not in allowed hosts
self.assertFalse(adapter.is_safe_url(url))
assert not adapter.is_safe_url("//evil.com")
@mock.patch("allauth.core.internal.ratelimit.consume", return_value=True)
def test_pre_authenticate(self, mock_consume) -> None:
def test_pre_authenticate(
self,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True)
adapter = get_adapter()
request = HttpRequest()
request.get_host = mock.Mock(return_value="example.com")
request.get_host = lambda: "example.com"
settings.DISABLE_REGULAR_LOGIN = False
adapter.pre_authenticate(request)
settings.DISABLE_REGULAR_LOGIN = True
with self.assertRaises(ValidationError):
with pytest.raises(ValidationError):
adapter.pre_authenticate(request)
def test_get_reset_password_from_key_url(self) -> None:
def test_get_reset_password_from_key_url(self, settings: SettingsWrapper) -> None:
request = HttpRequest()
request.get_host = mock.Mock(return_value="foo.org")
request.get_host = lambda: "foo.org"
with context.request_context(request):
adapter = get_adapter()
# Test when PAPERLESS_URL is None
with override_settings(
PAPERLESS_URL=None,
ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
):
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
self.assertEqual(
adapter.get_reset_password_from_key_url("UID-KEY"),
expected_url,
)
settings.PAPERLESS_URL = None
settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url
# Test when PAPERLESS_URL is not None
with override_settings(PAPERLESS_URL="https://bar.com"):
expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
self.assertEqual(
adapter.get_reset_password_from_key_url("UID-KEY"),
expected_url,
)
settings.PAPERLESS_URL = "https://bar.com"
expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url
@override_settings(ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
def test_save_user_adds_groups(self) -> None:
def test_save_user_adds_groups(
self,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
Group.objects.create(name="group1")
user = User.objects.create_user("testuser")
adapter = get_adapter()
form = mock.Mock(
form = mocker.MagicMock(
cleaned_data={
"username": "testuser",
"email": "user@example.com",
@@ -110,88 +103,81 @@ class TestCustomAccountAdapter(TestCase):
user = adapter.save_user(HttpRequest(), user, form, commit=True)
self.assertEqual(user.groups.count(), 1)
self.assertTrue(user.groups.filter(name="group1").exists())
self.assertFalse(user.groups.filter(name="group2").exists())
assert user.groups.count() == 1
assert user.groups.filter(name="group1").exists()
assert not user.groups.filter(name="group2").exists()
def test_fresh_install_save_creates_superuser(self) -> None:
def test_fresh_install_save_creates_superuser(self, mocker: MockerFixture) -> None:
adapter = get_adapter()
form = mock.Mock(
form = mocker.MagicMock(
cleaned_data={
"username": "testuser",
"email": "user@paperless-ngx.com",
},
)
user = adapter.save_user(HttpRequest(), User(), form, commit=True)
self.assertTrue(user.is_superuser)
assert user.is_superuser
# Next time, it should not create a superuser
form = mock.Mock(
form = mocker.MagicMock(
cleaned_data={
"username": "testuser2",
"email": "user2@paperless-ngx.com",
},
)
user2 = adapter.save_user(HttpRequest(), User(), form, commit=True)
self.assertFalse(user2.is_superuser)
assert not user2.is_superuser
class TestCustomSocialAccountAdapter(TestCase):
def test_is_open_for_signup(self) -> None:
class TestCustomSocialAccountAdapter:
@pytest.mark.django_db
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
adapter = get_social_adapter()
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is True
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
self.assertTrue(adapter.is_open_for_signup(None, None))
assert adapter.is_open_for_signup(None, None)
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is False
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False
self.assertFalse(adapter.is_open_for_signup(None, None))
assert not adapter.is_open_for_signup(None, None)
def test_get_connect_redirect_url(self) -> None:
adapter = get_social_adapter()
request = None
socialaccount = None
assert adapter.get_connect_redirect_url(None, None) == reverse("base")
# Test the default URL
expected_url = reverse("base")
self.assertEqual(
adapter.get_connect_redirect_url(request, socialaccount),
expected_url,
)
@override_settings(SOCIAL_ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
def test_save_user_adds_groups(self) -> None:
@pytest.mark.django_db
def test_save_user_adds_groups(
self,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
Group.objects.create(name="group1")
adapter = get_social_adapter()
request = HttpRequest()
user = User.objects.create_user("testuser")
sociallogin = mock.Mock(
user=user,
)
sociallogin = mocker.MagicMock(user=user)
user = adapter.save_user(request, sociallogin, None)
user = adapter.save_user(HttpRequest(), sociallogin, None)
self.assertEqual(user.groups.count(), 1)
self.assertTrue(user.groups.filter(name="group1").exists())
self.assertFalse(user.groups.filter(name="group2").exists())
assert user.groups.count() == 1
assert user.groups.filter(name="group1").exists()
assert not user.groups.filter(name="group2").exists()
def test_error_logged_on_authentication_error(self) -> None:
def test_error_logged_on_authentication_error(
self,
caplog: pytest.LogCaptureFixture,
) -> None:
adapter = get_social_adapter()
request = HttpRequest()
with self.assertLogs("paperless.auth", level="INFO") as log_cm:
with caplog.at_level(logging.INFO, logger="paperless.auth"):
adapter.on_authentication_error(
request,
HttpRequest(),
provider="test-provider",
error="Error",
exception="Test authentication error",
)
self.assertTrue(
any("Test authentication error" in message for message in log_cm.output),
)
assert any("Test authentication error" in msg for msg in caplog.messages)
class TestDrfTokenStrategy(TestCase):
@pytest.mark.django_db
class TestDrfTokenStrategy:
def test_create_access_token_creates_new_token(self) -> None:
"""
GIVEN:
@@ -201,7 +187,6 @@ class TestDrfTokenStrategy(TestCase):
THEN:
- A new token is created and its key is returned
"""
user = User.objects.create_user("testuser")
request = HttpRequest()
request.user = user
@@ -209,13 +194,9 @@ class TestDrfTokenStrategy(TestCase):
strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request)
# Verify a token was created
self.assertIsNotNone(token_key)
self.assertTrue(Token.objects.filter(user=user).exists())
# Verify the returned key matches the created token
token = Token.objects.get(user=user)
self.assertEqual(token_key, token.key)
assert token_key is not None
assert Token.objects.filter(user=user).exists()
assert token_key == Token.objects.get(user=user).key
def test_create_access_token_returns_existing_token(self) -> None:
"""
@@ -226,7 +207,6 @@ class TestDrfTokenStrategy(TestCase):
THEN:
- The same token key is returned (no new token created)
"""
user = User.objects.create_user("testuser")
existing_token = Token.objects.create(user=user)
@@ -236,11 +216,8 @@ class TestDrfTokenStrategy(TestCase):
strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request)
# Verify the existing token key is returned
self.assertEqual(token_key, existing_token.key)
# Verify only one token exists (no duplicate created)
self.assertEqual(Token.objects.filter(user=user).count(), 1)
assert token_key == existing_token.key
assert Token.objects.filter(user=user).count() == 1
def test_create_access_token_returns_none_for_unauthenticated_user(self) -> None:
"""
@@ -251,12 +228,11 @@ class TestDrfTokenStrategy(TestCase):
THEN:
- None is returned and no token is created
"""
request = HttpRequest()
request.user = AnonymousUser()
strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request)
self.assertIsNone(token_key)
self.assertEqual(Token.objects.count(), 0)
assert token_key is None
assert Token.objects.count() == 0

View File

@@ -1,15 +1,12 @@
import os
from dataclasses import dataclass
from pathlib import Path
from unittest import mock
import pytest
from django.core.checks import Warning
from django.test import TestCase
from django.test import override_settings
from pytest_django.fixtures import SettingsWrapper
from pytest_mock import MockerFixture
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin
from paperless.checks import audit_log_check
from paperless.checks import binaries_check
from paperless.checks import check_deprecated_db_settings
@@ -18,54 +15,84 @@ from paperless.checks import paths_check
from paperless.checks import settings_values_check
class TestChecks(DirectoriesMixin, TestCase):
def test_binaries(self) -> None:
self.assertEqual(binaries_check(None), [])
@dataclass(frozen=True, slots=True)
class PaperlessTestDirs:
data_dir: Path
media_dir: Path
consumption_dir: Path
@override_settings(CONVERT_BINARY="uuuhh")
def test_binaries_fail(self) -> None:
self.assertEqual(len(binaries_check(None)), 1)
def test_paths_check(self) -> None:
self.assertEqual(paths_check(None), [])
# TODO: consolidate with documents/tests/conftest.py PaperlessDirs/paperless_dirs
# once the paperless and documents test suites are ready to share fixtures.
@pytest.fixture()
def directories(tmp_path: Path, settings: SettingsWrapper) -> PaperlessTestDirs:
data_dir = tmp_path / "data"
media_dir = tmp_path / "media"
consumption_dir = tmp_path / "consumption"
@override_settings(
MEDIA_ROOT=Path("uuh"),
DATA_DIR=Path("whatever"),
CONSUMPTION_DIR=Path("idontcare"),
for d in (data_dir, media_dir, consumption_dir):
d.mkdir()
settings.DATA_DIR = data_dir
settings.MEDIA_ROOT = media_dir
settings.CONSUMPTION_DIR = consumption_dir
return PaperlessTestDirs(
data_dir=data_dir,
media_dir=media_dir,
consumption_dir=consumption_dir,
)
def test_paths_check_dont_exist(self) -> None:
msgs = paths_check(None)
self.assertEqual(len(msgs), 3, str(msgs))
for msg in msgs:
self.assertTrue(msg.msg.endswith("is set but doesn't exist."))
def test_paths_check_no_access(self) -> None:
Path(self.dirs.data_dir).chmod(0o000)
Path(self.dirs.media_dir).chmod(0o000)
Path(self.dirs.consumption_dir).chmod(0o000)
class TestChecks:
def test_binaries(self) -> None:
assert binaries_check(None) == []
self.addCleanup(os.chmod, self.dirs.data_dir, 0o777)
self.addCleanup(os.chmod, self.dirs.media_dir, 0o777)
self.addCleanup(os.chmod, self.dirs.consumption_dir, 0o777)
def test_binaries_fail(self, settings: SettingsWrapper) -> None:
settings.CONVERT_BINARY = "uuuhh"
assert len(binaries_check(None)) == 1
@pytest.mark.usefixtures("directories")
def test_paths_check(self) -> None:
assert paths_check(None) == []
def test_paths_check_dont_exist(self, settings: SettingsWrapper) -> None:
settings.MEDIA_ROOT = Path("uuh")
settings.DATA_DIR = Path("whatever")
settings.CONSUMPTION_DIR = Path("idontcare")
msgs = paths_check(None)
self.assertEqual(len(msgs), 3)
assert len(msgs) == 3, str(msgs)
for msg in msgs:
self.assertTrue(msg.msg.endswith("is not writeable"))
assert msg.msg.endswith("is set but doesn't exist.")
@override_settings(DEBUG=False)
def test_debug_disabled(self) -> None:
self.assertEqual(debug_mode_check(None), [])
def test_paths_check_no_access(self, directories: PaperlessTestDirs) -> None:
directories.data_dir.chmod(0o000)
directories.media_dir.chmod(0o000)
directories.consumption_dir.chmod(0o000)
@override_settings(DEBUG=True)
def test_debug_enabled(self) -> None:
self.assertEqual(len(debug_mode_check(None)), 1)
try:
msgs = paths_check(None)
finally:
directories.data_dir.chmod(0o777)
directories.media_dir.chmod(0o777)
directories.consumption_dir.chmod(0o777)
assert len(msgs) == 3
for msg in msgs:
assert msg.msg.endswith("is not writeable")
def test_debug_disabled(self, settings: SettingsWrapper) -> None:
settings.DEBUG = False
assert debug_mode_check(None) == []
def test_debug_enabled(self, settings: SettingsWrapper) -> None:
settings.DEBUG = True
assert len(debug_mode_check(None)) == 1
class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase):
class TestSettingsChecksAgainstDefaults:
def test_all_valid(self) -> None:
"""
GIVEN:
@@ -76,104 +103,71 @@ class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase):
- No system check errors reported
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 0)
assert len(msgs) == 0
class TestOcrSettingsChecks(DirectoriesMixin, TestCase):
@override_settings(OCR_OUTPUT_TYPE="notapdf")
def test_invalid_output_type(self) -> None:
class TestOcrSettingsChecks:
@pytest.mark.parametrize(
("setting", "value", "expected_msg"),
[
pytest.param(
"OCR_OUTPUT_TYPE",
"notapdf",
'OCR output type "notapdf"',
id="invalid-output-type",
),
pytest.param(
"OCR_MODE",
"makeitso",
'OCR output mode "makeitso"',
id="invalid-mode",
),
pytest.param(
"OCR_MODE",
"skip_noarchive",
"deprecated",
id="deprecated-mode",
),
pytest.param(
"OCR_SKIP_ARCHIVE_FILE",
"invalid",
'OCR_SKIP_ARCHIVE_FILE setting "invalid"',
id="invalid-skip-archive-file",
),
pytest.param(
"OCR_CLEAN",
"cleanme",
'OCR clean mode "cleanme"',
id="invalid-clean",
),
],
)
def test_invalid_setting_produces_one_error(
self,
settings: SettingsWrapper,
setting: str,
value: str,
expected_msg: str,
) -> None:
"""
GIVEN:
- Default settings
- OCR output type is invalid
- One OCR setting is set to an invalid value
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR output type
- Exactly one system check error is reported containing the expected message
"""
setattr(settings, setting, value)
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR output type "notapdf"', msg.msg)
@override_settings(OCR_MODE="makeitso")
def test_invalid_ocr_type(self) -> None:
"""
GIVEN:
- Default settings
- OCR type is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR output mode "makeitso"', msg.msg)
@override_settings(OCR_MODE="skip_noarchive")
def test_deprecated_ocr_type(self) -> None:
"""
GIVEN:
- Default settings
- OCR type is deprecated
WHEN:
- Settings are validated
THEN:
- deprecation warning reported for OCR type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn("deprecated", msg.msg)
@override_settings(OCR_SKIP_ARCHIVE_FILE="invalid")
def test_invalid_ocr_skip_archive_file(self) -> None:
"""
GIVEN:
- Default settings
- OCR_SKIP_ARCHIVE_FILE is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR_SKIP_ARCHIVE_FILE
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR_SKIP_ARCHIVE_FILE setting "invalid"', msg.msg)
@override_settings(OCR_CLEAN="cleanme")
def test_invalid_ocr_clean(self) -> None:
"""
GIVEN:
- Default settings
- OCR cleaning type is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR cleaning type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR clean mode "cleanme"', msg.msg)
assert len(msgs) == 1
assert expected_msg in msgs[0].msg
class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase):
@override_settings(TIME_ZONE="TheMoon\\MyCrater")
def test_invalid_timezone(self) -> None:
class TestTimezoneSettingsChecks:
def test_invalid_timezone(self, settings: SettingsWrapper) -> None:
"""
GIVEN:
- Default settings
@@ -183,17 +177,16 @@ class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase):
THEN:
- system check error reported for timezone
"""
settings.TIME_ZONE = "TheMoon\\MyCrater"
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg)
assert len(msgs) == 1
assert 'Timezone "TheMoon\\MyCrater"' in msgs[0].msg
class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
@override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem"))
def test_not_valid_file(self) -> None:
class TestEmailCertSettingsChecks:
def test_not_valid_file(self, settings: SettingsWrapper) -> None:
"""
GIVEN:
- Default settings
@@ -203,19 +196,22 @@ class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, Test
THEN:
- system check error reported for email certificate
"""
self.assertIsNotFile("/tmp/not_actually_here.pem")
cert_path = Path("/tmp/not_actually_here.pem")
assert not cert_path.is_file()
settings.EMAIL_CERTIFICATE_FILE = cert_path
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg)
assert len(msgs) == 1
assert "Email cert /tmp/not_actually_here.pem is not a file" in msgs[0].msg
class TestAuditLogChecks(TestCase):
def test_was_enabled_once(self) -> None:
class TestAuditLogChecks:
def test_was_enabled_once(
self,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
"""
GIVEN:
- Audit log is not enabled
@@ -224,23 +220,18 @@ class TestAuditLogChecks(TestCase):
THEN:
- system check error reported for disabling audit log
"""
introspect_mock = mock.MagicMock()
settings.AUDIT_LOG_ENABLED = False
introspect_mock = mocker.MagicMock()
introspect_mock.introspection.table_names.return_value = ["auditlog_logentry"]
with override_settings(AUDIT_LOG_ENABLED=False):
with mock.patch.dict(
"paperless.checks.connections",
{"default": introspect_mock},
):
msgs = audit_log_check(None)
mocker.patch.dict(
"paperless.checks.connections",
{"default": introspect_mock},
)
self.assertEqual(len(msgs), 1)
msgs = audit_log_check(None)
msg = msgs[0]
self.assertIn(
("auditlog table was found but audit log is disabled."),
msg.msg,
)
assert len(msgs) == 1
assert "auditlog table was found but audit log is disabled." in msgs[0].msg
DEPRECATED_VARS: dict[str, str] = {
@@ -269,20 +260,16 @@ class TestDeprecatedDbSettings:
@pytest.mark.parametrize(
("env_var", "db_option_key"),
[
("PAPERLESS_DB_TIMEOUT", "timeout"),
("PAPERLESS_DB_POOLSIZE", "pool.min_size / pool.max_size"),
("PAPERLESS_DBSSLMODE", "sslmode"),
("PAPERLESS_DBSSLROOTCERT", "sslrootcert"),
("PAPERLESS_DBSSLCERT", "sslcert"),
("PAPERLESS_DBSSLKEY", "sslkey"),
],
ids=[
"db-timeout",
"db-poolsize",
"ssl-mode",
"ssl-rootcert",
"ssl-cert",
"ssl-key",
pytest.param("PAPERLESS_DB_TIMEOUT", "timeout", id="db-timeout"),
pytest.param(
"PAPERLESS_DB_POOLSIZE",
"pool.min_size / pool.max_size",
id="db-poolsize",
),
pytest.param("PAPERLESS_DBSSLMODE", "sslmode", id="ssl-mode"),
pytest.param("PAPERLESS_DBSSLROOTCERT", "sslrootcert", id="ssl-rootcert"),
pytest.param("PAPERLESS_DBSSLCERT", "sslcert", id="ssl-cert"),
pytest.param("PAPERLESS_DBSSLKEY", "sslkey", id="ssl-key"),
],
)
def test_single_deprecated_var_produces_one_warning(

View File

@@ -9,35 +9,50 @@ from paperless.utils import ocr_to_dateparser_languages
@pytest.mark.parametrize(
("ocr_language", "expected"),
[
# One language
("eng", ["en"]),
# Multiple languages
("fra+ita+lao", ["fr", "it", "lo"]),
# Languages that don't have a two-letter equivalent
("fil", ["fil"]),
# Languages with a script part supported by dateparser
("aze_cyrl+srp_latn", ["az-Cyrl", "sr-Latn"]),
# Languages with a script part not supported by dateparser
# In this case, default to the language without script
("deu_frak", ["de"]),
# Traditional and simplified chinese don't have the same name in dateparser,
# so they're converted to the general chinese language
("chi_tra+chi_sim", ["zh"]),
# If a language is not supported by dateparser, fallback to the supported ones
("eng+unsupported_language+por", ["en", "pt"]),
# If no language is supported, fallback to default
("unsupported1+unsupported2", []),
# Duplicate languages, should not duplicate in result
("eng+eng", ["en"]),
# Language with script, but script is not mapped
("ita_unknownscript", ["it"]),
pytest.param("eng", ["en"], id="single-language"),
pytest.param("fra+ita+lao", ["fr", "it", "lo"], id="multiple-languages"),
pytest.param("fil", ["fil"], id="no-two-letter-equivalent"),
pytest.param(
"aze_cyrl+srp_latn",
["az-Cyrl", "sr-Latn"],
id="script-supported-by-dateparser",
),
pytest.param(
"deu_frak",
["de"],
id="script-not-supported-falls-back-to-language",
),
pytest.param(
"chi_tra+chi_sim",
["zh"],
id="chinese-variants-collapse-to-general",
),
pytest.param(
"eng+unsupported_language+por",
["en", "pt"],
id="unsupported-language-skipped",
),
pytest.param(
"unsupported1+unsupported2",
[],
id="all-unsupported-returns-empty",
),
pytest.param("eng+eng", ["en"], id="duplicates-deduplicated"),
pytest.param(
"ita_unknownscript",
["it"],
id="unknown-script-falls-back-to-language",
),
],
)
def test_ocr_to_dateparser_languages(ocr_language, expected):
def test_ocr_to_dateparser_languages(ocr_language: str, expected: list[str]) -> None:
assert sorted(ocr_to_dateparser_languages(ocr_language)) == sorted(expected)
def test_ocr_to_dateparser_languages_exception(monkeypatch, caplog):
def test_ocr_to_dateparser_languages_exception(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
# Patch LocaleDataLoader.get_locale_map to raise an exception
class DummyLoader:
def get_locale_map(self, locales=None):

View File

@@ -1,24 +1,31 @@
import tempfile
from pathlib import Path
from django.test import override_settings
from django.test import Client
from pytest_django.fixtures import SettingsWrapper
def test_favicon_view(client):
with tempfile.TemporaryDirectory() as tmpdir:
static_dir = Path(tmpdir)
favicon_path = static_dir / "paperless" / "img" / "favicon.ico"
favicon_path.parent.mkdir(parents=True, exist_ok=True)
favicon_path.write_bytes(b"FAKE ICON DATA")
def test_favicon_view(
client: Client,
tmp_path: Path,
settings: SettingsWrapper,
) -> None:
favicon_path = tmp_path / "paperless" / "img" / "favicon.ico"
favicon_path.parent.mkdir(parents=True)
favicon_path.write_bytes(b"FAKE ICON DATA")
with override_settings(STATIC_ROOT=static_dir):
response = client.get("/favicon.ico")
assert response.status_code == 200
assert response["Content-Type"] == "image/x-icon"
assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
settings.STATIC_ROOT = tmp_path
response = client.get("/favicon.ico")
assert response.status_code == 200
assert response["Content-Type"] == "image/x-icon"
assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
def test_favicon_view_missing_file(client):
with override_settings(STATIC_ROOT=Path(tempfile.mkdtemp())):
response = client.get("/favicon.ico")
assert response.status_code == 404
def test_favicon_view_missing_file(
client: Client,
tmp_path: Path,
settings: SettingsWrapper,
) -> None:
settings.STATIC_ROOT = tmp_path
response = client.get("/favicon.ico")
assert response.status_code == 404

View File

@@ -48,20 +48,6 @@ class TestWebSockets(TestCase):
mock_close.assert_called_once()
mock_close.reset_mock()
message = {
"type": "document_updated",
"data": {"document_id": 10, "modified": "2026-02-17T00:00:00Z"},
}
await channel_layer.group_send(
"status_updates",
message,
)
await communicator.receive_nothing()
mock_close.assert_called_once()
mock_close.reset_mock()
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
await channel_layer.group_send(
@@ -172,40 +158,6 @@ class TestWebSockets(TestCase):
await communicator.disconnect()
@mock.patch("paperless.consumers.StatusConsumer._can_view")
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_receive_document_updated(self, _authenticated, _can_view) -> None:
_authenticated.return_value = True
_can_view.return_value = True
communicator = WebsocketCommunicator(application, "/ws/status/")
connected, _ = await communicator.connect()
self.assertTrue(connected)
message = {
"type": "document_updated",
"data": {
"document_id": 10,
"modified": "2026-02-17T00:00:00Z",
"owner_id": 1,
"users_can_view": [1],
"groups_can_view": [],
},
}
channel_layer = get_channel_layer()
assert channel_layer is not None
await channel_layer.group_send(
"status_updates",
message,
)
response = await communicator.receive_json_from()
self.assertEqual(response, message)
await communicator.disconnect()
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
def test_manager_send_progress(self, mock_group_send) -> None:
with ProgressManager(task_id="test") as manager:
@@ -238,10 +190,7 @@ class TestWebSockets(TestCase):
)
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
def test_manager_send_documents_deleted(
self,
mock_group_send: mock.MagicMock,
) -> None:
def test_manager_send_documents_deleted(self, mock_group_send) -> None:
with DocumentsStatusManager() as manager:
manager.send_documents_deleted([1, 2, 3])