mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-14 12:07:12 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b420fc1954 | ||
|
|
05321b10d3 |
No files matched your search
@@ -794,17 +794,6 @@ system. See the corresponding
|
|||||||
|
|
||||||
Defaults to None
|
Defaults to None
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) {#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR}
|
|
||||||
|
|
||||||
: A list of authentication method values which, if present in the `amr` claim of the ID token sent by an OpenID Connect provider, cause Paperless-ngx to skip its own two-factor authentication prompt for that login. This avoids users having to enter a second factor twice when the identity provider already performed multi-factor authentication. Logins with a username and password, or via a provider which does not send a matching `amr` value, still require the Paperless-ngx code.
|
|
||||||
|
|
||||||
Which values to trust depends entirely on your identity provider, e.g. Authelia sends `mfa` and Pocket ID sends `phr` for passkey logins (but `otp` for single-factor email codes, which should _not_ be trusted). Some providers, such as Keycloak, do not send an `amr` claim by default and need to be configured to do so. You can see what your provider sends from the Django admin as a superuser by checking `id_token` → `amr` in the "Extra data" of the social account.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
Only list values that mean a second factor was actually used for the login.
|
|
||||||
|
|
||||||
Defaults to `mfa`, the standard value for multi-factor authentication ([RFC 8176](https://www.rfc-editor.org/rfc/rfc8176.html)).
|
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||||
|
|
||||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||||
|
|||||||
@@ -473,8 +473,6 @@ Users can enable two-factor authentication (2FA) for their accounts from the 'My
|
|||||||
|
|
||||||
Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen.
|
Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen.
|
||||||
|
|
||||||
If users log in via an OpenID Connect provider which already enforces multi-factor authentication, see [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR`](configuration.md#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) to skip the Paperless-ngx prompt for those logins.
|
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|||||||
@@ -29,8 +29,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 header-search mx-auto">
|
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search class="flex-grow-1"></pngx-global-search>
|
||||||
|
<button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }"
|
||||||
|
class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center flex-shrink-0 py-0 px-2" type="button"
|
||||||
|
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
|
||||||
|
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul ngbNav class="order-sm-3">
|
<ul ngbNav class="order-sm-3">
|
||||||
@@ -70,6 +75,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
|
||||||
|
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
|
|||||||
@@ -532,7 +532,23 @@ main {
|
|||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 55rem;
|
max-width: 50rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search pngx-global-search {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// light w shadow to stand out against search
|
||||||
|
.header-upload {
|
||||||
|
--bs-btn-bg: rgba(255, 255, 255, .15);
|
||||||
|
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
|
||||||
|
--bs-btn-active-bg: rgba(255, 255, 255, .3);
|
||||||
|
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
|
||||||
|
|
||||||
|
&:not(:focus) {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2); // leave room for the .btn:focus ring
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
@@ -662,3 +678,7 @@ main {
|
|||||||
::ng-deep .navItemDrag .position-absolute i-bs {
|
::ng-deep .navItemDrag .position-absolute i-bs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.smaller {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import { SearchService } from 'src/app/services/rest/search.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
@@ -462,6 +463,29 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(routerSpy).toHaveBeenCalled()
|
expect(routerSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should upload files selected from the navbar upload button', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
const uploadSpy = jest
|
||||||
|
.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const file = new File(['testing'], 'file.pdf')
|
||||||
|
const input = window.document.createElement('input')
|
||||||
|
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
|
||||||
|
|
||||||
|
component.onFileSelected({ target: input } as unknown as Event)
|
||||||
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
expect(uploadSpy).toHaveBeenCalledWith([file])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not upload when no files are selected from the navbar', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||||
|
const input = window.document.createElement('input')
|
||||||
|
component.onFileSelected({ target: input } as unknown as Event)
|
||||||
|
expect(uploadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should close all documents on logout', () => {
|
it('should close all documents on logout', () => {
|
||||||
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
||||||
component.onLogout()
|
component.onLogout()
|
||||||
|
|||||||
@@ -48,12 +48,13 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
import { ChatComponent } from '../chat/chat/chat.component'
|
||||||
|
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -101,6 +102,7 @@ export class AppFrameComponent
|
|||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private djangoMessagesService = inject(DjangoMessagesService)
|
private djangoMessagesService = inject(DjangoMessagesService)
|
||||||
|
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||||
|
|
||||||
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
@@ -511,6 +513,15 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = Array.from(input.files ?? [])
|
||||||
|
input.value = ''
|
||||||
|
if (!files.length) return
|
||||||
|
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||||
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
onLogout() {
|
onLogout() {
|
||||||
this.openDocumentsService.closeAll()
|
this.openDocumentsService.closeAll()
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-8
@@ -85,15 +85,11 @@ describe('UploadFileWidgetComponent', () => {
|
|||||||
'file.pdf'
|
'file.pdf'
|
||||||
)
|
)
|
||||||
const fileInput = fixture.debugElement.query(By.css('input'))
|
const fileInput = fixture.debugElement.query(By.css('input'))
|
||||||
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({
|
jest
|
||||||
item: () => file,
|
.spyOn(fileInput.nativeElement, 'files', 'get')
|
||||||
length: 1,
|
.mockReturnValue([file] as any)
|
||||||
[Symbol.iterator]: () => ({
|
|
||||||
next: () => ({ done: false, value: file }),
|
|
||||||
}),
|
|
||||||
} as any)
|
|
||||||
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
||||||
expect(uploadSpy).toHaveBeenCalled()
|
expect(uploadSpy).toHaveBeenCalledWith(file)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should generate stats summary', () => {
|
it('should generate stats summary', () => {
|
||||||
|
|||||||
+3
-5
@@ -130,11 +130,9 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onFileSelected(event: Event) {
|
public onFileSelected(event: Event) {
|
||||||
const files = (event.target as HTMLInputElement).files
|
const input = event.target as HTMLInputElement
|
||||||
for (let i = 0; i < files?.length; i++) {
|
this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? []))
|
||||||
const file = files.item(i)
|
input.value = ''
|
||||||
file && this.uploadDocumentsService.uploadFile(file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export class FileDropComponent {
|
|||||||
.then((results) => {
|
.then((results) => {
|
||||||
files.push(...[].concat(...results))
|
files.push(...[].concat(...results))
|
||||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||||
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -144,7 +144,7 @@ export class FileDropComponent {
|
|||||||
})
|
})
|
||||||
} else if (files.length) {
|
} else if (files.length) {
|
||||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||||
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onDragLeave(event, true)
|
this.onDragLeave(event, true)
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ describe('UploadDocumentsService', () => {
|
|||||||
req[0].flush('123-456')
|
req[0].flush('123-456')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uploads each file when given multiple files', () => {
|
||||||
|
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFile')
|
||||||
|
const file1 = new File(['one'], 'one.pdf')
|
||||||
|
const file2 = new File(['two'], 'two.pdf')
|
||||||
|
uploadDocumentsService.uploadFiles([file1, file2])
|
||||||
|
expect(uploadSpy).toHaveBeenCalledWith(file1)
|
||||||
|
expect(uploadSpy).toHaveBeenCalledWith(file2)
|
||||||
|
httpTestingController
|
||||||
|
.match(`${environment.apiBaseUrl}documents/post_document/`)
|
||||||
|
.forEach((req) => req.flush('123-456'))
|
||||||
|
})
|
||||||
|
|
||||||
it('updates progress during upload and failure', () => {
|
it('updates progress during upload and failure', () => {
|
||||||
const file = new File(
|
const file = new File(
|
||||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export class UploadDocumentsService {
|
|||||||
|
|
||||||
private uploadSubscriptions: Array<Subscription> = []
|
private uploadSubscriptions: Array<Subscription> = []
|
||||||
|
|
||||||
|
public uploadFiles(files: Iterable<File>) {
|
||||||
|
for (const file of files) {
|
||||||
|
this.uploadFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public uploadFile(file: File) {
|
public uploadFile(file: File) {
|
||||||
let formData = new FormData()
|
let formData = new FormData()
|
||||||
formData.append('document', file, file.name)
|
formData.append('document', file, file.name)
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
eye,
|
eye,
|
||||||
fileEarmark,
|
fileEarmark,
|
||||||
|
fileEarmarkArrowUp,
|
||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
@@ -341,6 +342,7 @@ const icons = {
|
|||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
eye,
|
eye,
|
||||||
fileEarmark,
|
fileEarmark,
|
||||||
|
fileEarmarkArrowUp,
|
||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
|
|||||||
@@ -13,14 +13,8 @@ from celery import group
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Case
|
|
||||||
from django.db.models import F
|
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.db.models import OuterRef
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models import Subquery
|
|
||||||
from django.db.models import When
|
|
||||||
from django.db.models.functions import Coalesce
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
@@ -42,7 +36,6 @@ from documents.tasks import remove_document_from_index
|
|||||||
from documents.tasks import update_document_content_maybe_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
from documents.versioning import versions_newest_first
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -415,28 +408,10 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
|||||||
|
|
||||||
Consumption workflows do not run here, so ``remote_ocr`` is how the user
|
Consumption workflows do not run here, so ``remote_ocr`` is how the user
|
||||||
asks for the remote engine when it is not configured to handle everything.
|
asks for the remote engine when it is not configured to handle everything.
|
||||||
|
|
||||||
A root document with versions reprocesses its latest version, which is the
|
|
||||||
file whose content, archive and thumbnail are shown for it.
|
|
||||||
"""
|
"""
|
||||||
latest_version = versions_newest_first(
|
for document_id in doc_ids:
|
||||||
Document.objects.filter(root_document=OuterRef("pk")),
|
|
||||||
).values("id")[:1]
|
|
||||||
source_ids = (
|
|
||||||
Document.objects.filter(id__in=doc_ids)
|
|
||||||
.annotate(
|
|
||||||
source_id=Case(
|
|
||||||
When(root_document__isnull=False, then=F("id")),
|
|
||||||
default=Coalesce(Subquery(latest_version), F("id")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.order_by()
|
|
||||||
.values_list("source_id", flat=True)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
for source_id in source_ids:
|
|
||||||
update_document_content_maybe_archive_file.apply_async(
|
update_document_content_maybe_archive_file.apply_async(
|
||||||
kwargs={"document_id": source_id, "remote_ocr": remote_ocr},
|
kwargs={"document_id": document_id, "remote_ocr": remote_ocr},
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from django.db.models import QuerySet
|
|||||||
from django.db.models import Value
|
from django.db.models import Value
|
||||||
from django.db.models import When
|
from django.db.models import When
|
||||||
from django.db.models.functions import Cast
|
from django.db.models.functions import Cast
|
||||||
from django.db.models.functions import Coalesce
|
|
||||||
from guardian.core import ObjectPermissionChecker
|
from guardian.core import ObjectPermissionChecker
|
||||||
from guardian.models import GroupObjectPermission
|
from guardian.models import GroupObjectPermission
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
@@ -475,29 +474,6 @@ def permitted_document_ids(
|
|||||||
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
|
return permitted_object_ids(user, Document, perm, include_deleted=include_deleted)
|
||||||
|
|
||||||
|
|
||||||
def documents_without_permitted_root(
|
|
||||||
documents: QuerySet[Document],
|
|
||||||
user: User | None,
|
|
||||||
*,
|
|
||||||
perm: str = "view_document",
|
|
||||||
include_deleted: bool = False,
|
|
||||||
) -> QuerySet[Document]:
|
|
||||||
"""
|
|
||||||
The documents the user lacks ``perm`` on. Versions are authorized by their
|
|
||||||
root document, so a version's own owner is ignored. A single query, without
|
|
||||||
loading the documents or joining the root.
|
|
||||||
"""
|
|
||||||
return documents.annotate(
|
|
||||||
root_id=Coalesce("root_document_id", "id"),
|
|
||||||
).exclude(
|
|
||||||
root_id__in=permitted_document_ids(
|
|
||||||
user,
|
|
||||||
perm=perm,
|
|
||||||
include_deleted=include_deleted,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_document_count_filter_for_user(user, related_name: str = "documents"):
|
def get_document_count_filter_for_user(user, related_name: str = "documents"):
|
||||||
"""
|
"""
|
||||||
Return the Q object used to filter document counts for the given user.
|
Return the Q object used to filter document counts for the given user.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -56,19 +55,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger("paperless.search")
|
logger = logging.getLogger("paperless.search")
|
||||||
|
|
||||||
# tantivy stores dates as signed 64-bit nanoseconds since the Unix epoch, which
|
|
||||||
# covers 1677-09-21T00:12:43 to 2262-04-11T23:47:16 UTC
|
|
||||||
_INDEX_DATE_NANOS_MIN: Final[int] = -(2**63)
|
|
||||||
_INDEX_DATE_NANOS_MAX: Final[int] = 2**63 - 1
|
|
||||||
_UNIX_EPOCH: Final[datetime] = datetime(1970, 1, 1, tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_indexable_date(value: datetime) -> bool:
|
|
||||||
"""Whether value, at whole-second precision, fits tantivy's date range."""
|
|
||||||
nanos = ((value - _UNIX_EPOCH) // timedelta(seconds=1)) * 1_000_000_000
|
|
||||||
return _INDEX_DATE_NANOS_MIN <= nanos <= _INDEX_DATE_NANOS_MAX
|
|
||||||
|
|
||||||
|
|
||||||
_LOCK_TIMEOUT_SECONDS: Final[float] = 10.0 # per-attempt acquire timeout
|
_LOCK_TIMEOUT_SECONDS: Final[float] = 10.0 # per-attempt acquire timeout
|
||||||
_LOCK_RETRY_ATTEMPTS: Final[int] = 4 # total attempts (1 initial + 3 retries)
|
_LOCK_RETRY_ATTEMPTS: Final[int] = 4 # total attempts (1 initial + 3 retries)
|
||||||
_LOCK_BACKOFF_BASE: Final[float] = 1.0 # seconds
|
_LOCK_BACKOFF_BASE: Final[float] = 1.0 # seconds
|
||||||
@@ -642,15 +628,7 @@ class TantivyBackend:
|
|||||||
document.created.day,
|
document.created.day,
|
||||||
tzinfo=UTC,
|
tzinfo=UTC,
|
||||||
)
|
)
|
||||||
if _is_indexable_date(created_date):
|
doc.add_date("created", created_date)
|
||||||
doc.add_date("created", created_date)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Document %s has a created date (%s) outside the range the search "
|
|
||||||
"index can store; it will be indexed without a created date",
|
|
||||||
document.pk,
|
|
||||||
document.created,
|
|
||||||
)
|
|
||||||
doc.add_date("modified", document.modified)
|
doc.add_date("modified", document.modified)
|
||||||
doc.add_date("added", document.added)
|
doc.add_date("added", document.added)
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
|||||||
from documents.templating.workflows import validate_workflow_template
|
from documents.templating.workflows import validate_workflow_template
|
||||||
from documents.validators import uri_validator
|
from documents.validators import uri_validator
|
||||||
from documents.validators import url_validator
|
from documents.validators import url_validator
|
||||||
from documents.versioning import get_root_document
|
|
||||||
from documents.versioning import has_prefetched_effective_content
|
from documents.versioning import has_prefetched_effective_content
|
||||||
from documents.versioning import sort_versions_newest_first
|
from documents.versioning import sort_versions_newest_first
|
||||||
|
|
||||||
@@ -2895,7 +2894,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
|||||||
and has_perms_owner_aware(
|
and has_perms_owner_aware(
|
||||||
self.user,
|
self.user,
|
||||||
"view_document",
|
"view_document",
|
||||||
get_root_document(document),
|
document,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
return document
|
return document
|
||||||
|
|||||||
@@ -490,23 +490,18 @@ def update_document_content_maybe_archive_file(
|
|||||||
shutil.move(thumbnail, document.thumbnail_path)
|
shutil.move(thumbnail, document.thumbnail_path)
|
||||||
|
|
||||||
document.refresh_from_db()
|
document.refresh_from_db()
|
||||||
root_document = (
|
|
||||||
document.root_document if document.root_document_id else document
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Updating index for document {root_document.pk} ({document.archive_checksum})",
|
f"Updating index for document {document_id} ({document.archive_checksum})",
|
||||||
)
|
)
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
get_backend().add_or_update(root_document)
|
get_backend().add_or_update(document)
|
||||||
|
|
||||||
ai_config = AIConfig()
|
ai_config = AIConfig()
|
||||||
if ai_config.llm_index_enabled:
|
if ai_config.llm_index_enabled:
|
||||||
llm_index_add_or_update_document(root_document)
|
llm_index_add_or_update_document(document)
|
||||||
|
|
||||||
clear_document_caches(document.pk)
|
clear_document_caches(document.pk)
|
||||||
if root_document.pk != document.pk:
|
|
||||||
clear_document_caches(root_document.pk)
|
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -906,79 +904,6 @@ class TestRebuild:
|
|||||||
assert ids == [doc.pk]
|
assert ids == [doc.pk]
|
||||||
|
|
||||||
|
|
||||||
class TestCreatedDateOutOfRange:
|
|
||||||
"""The index stores dates as nanosecond i64 values (1677-09-22 to 2262-04-11).
|
|
||||||
|
|
||||||
A document whose created date falls outside that window must not abort
|
|
||||||
indexing: it is indexed without a created value and a warning names it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("created", "expected_warnings"),
|
|
||||||
[
|
|
||||||
pytest.param(date(1677, 9, 22), 0, id="first-representable-day"),
|
|
||||||
pytest.param(date(2262, 4, 11), 0, id="last-representable-day"),
|
|
||||||
pytest.param(date(1677, 9, 21), 1, id="day-before-first"),
|
|
||||||
pytest.param(date(2262, 4, 12), 1, id="day-after-last"),
|
|
||||||
pytest.param(date(16, 8, 30), 1, id="two-digit-year-read-as-year-16"),
|
|
||||||
pytest.param(date(9999, 12, 31), 1, id="max-python-date"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_add_or_update_indexes_document_and_warns_when_out_of_range(
|
|
||||||
self,
|
|
||||||
backend: TantivyBackend,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
created: date,
|
|
||||||
expected_warnings: int,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document with a created date at or beyond the index date limits
|
|
||||||
WHEN:
|
|
||||||
- The document is added to the index
|
|
||||||
THEN:
|
|
||||||
- The document is indexed and searchable either way
|
|
||||||
- A warning naming the document is logged only for out-of-range dates
|
|
||||||
"""
|
|
||||||
doc = DocumentFactory(created=created, content="boundarycontent")
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="paperless.search"):
|
|
||||||
backend.add_or_update(doc)
|
|
||||||
|
|
||||||
assert backend.search_ids("boundarycontent", user=None) == [doc.pk]
|
|
||||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
||||||
assert len(warnings) == expected_warnings
|
|
||||||
if expected_warnings:
|
|
||||||
assert f"Document {doc.pk}" in warnings[0].getMessage()
|
|
||||||
|
|
||||||
def test_rebuild_continues_past_out_of_range_document(
|
|
||||||
self,
|
|
||||||
backend: TantivyBackend,
|
|
||||||
caplog: pytest.LogCaptureFixture,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A document with an unrepresentable created date among valid ones
|
|
||||||
WHEN:
|
|
||||||
- The index is rebuilt
|
|
||||||
THEN:
|
|
||||||
- Rebuild completes and every document is searchable
|
|
||||||
- A warning names the offending document
|
|
||||||
"""
|
|
||||||
good = DocumentFactory(created=date(2016, 8, 30), content="rebuildcontent")
|
|
||||||
bad = DocumentFactory(created=date(16, 8, 30), content="rebuildcontent")
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="paperless.search"):
|
|
||||||
backend.rebuild(Document.objects.all())
|
|
||||||
|
|
||||||
assert sorted(backend.search_ids("rebuildcontent", user=None)) == sorted(
|
|
||||||
[good.pk, bad.pk],
|
|
||||||
)
|
|
||||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
||||||
assert len(warnings) == 1
|
|
||||||
assert f"Document {bad.pk}" in warnings[0].getMessage()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutocomplete:
|
class TestAutocomplete:
|
||||||
"""Test autocomplete functionality."""
|
"""Test autocomplete functionality."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from auditlog.models import LogEntry # type: ignore[import-untyped]
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase as DjangoTestCase
|
from django.test import TestCase as DjangoTestCase
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -17,17 +16,13 @@ from documents.data_models import DocumentSource
|
|||||||
from documents.filters import EffectiveContentFilter
|
from documents.filters import EffectiveContentFilter
|
||||||
from documents.filters import TitleContentFilter
|
from documents.filters import TitleContentFilter
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import Note
|
|
||||||
from documents.models import ShareLink
|
|
||||||
from documents.versioning import annotate_effective_content
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.views import DocumentSelectionMixin
|
from documents.views import DocumentSelectionMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
from paperless_testing.factories import DocumentFactory
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.http import read_streaming_response
|
from paperless_testing.http import read_streaming_response
|
||||||
from paperless_testing.permissions import grant_all_global
|
|
||||||
from paperless_testing.permissions import grant_global
|
from paperless_testing.permissions import grant_global
|
||||||
from paperless_testing.permissions import grant_object
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -1048,152 +1043,3 @@ class TestBulkSelectionExcludesVersions(DjangoTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(selected, [root.id])
|
self.assertEqual(selected, [root.id])
|
||||||
|
|
||||||
|
|
||||||
class TestVersionActionPermissions(DirectoriesMixin, APITestCase):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp()
|
|
||||||
self.user = UserFactory()
|
|
||||||
grant_all_global(self.user)
|
|
||||||
self.client.force_authenticate(self.user)
|
|
||||||
self.root = DocumentFactory(owner=UserFactory())
|
|
||||||
self.version = DocumentFactory(root_document=self.root, owner=None)
|
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
|
||||||
def test_actions_reject_stale_version_ownership(self):
|
|
||||||
note = Note.objects.create(document=self.version, note="Version note")
|
|
||||||
for owner in (None, self.user):
|
|
||||||
self.version.owner = owner
|
|
||||||
self.version.save(update_fields=["owner"])
|
|
||||||
for action in (
|
|
||||||
"notes",
|
|
||||||
"suggestions",
|
|
||||||
"ai_suggestions",
|
|
||||||
"history",
|
|
||||||
"share_links",
|
|
||||||
):
|
|
||||||
with self.subTest(owner=owner, action=action):
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/{self.version.pk}/{action}/",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
response = self.client.post(
|
|
||||||
f"/api/documents/{self.version.pk}/notes/",
|
|
||||||
{"note": "New note"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
response = self.client.delete(
|
|
||||||
f"/api/documents/{self.version.pk}/notes/?id={note.pk}",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/share_links/",
|
|
||||||
{"document": self.version.pk, "file_version": "original"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/share_link_bundles/",
|
|
||||||
{"document_ids": [self.version.pk], "file_version": "original"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/email/",
|
|
||||||
{
|
|
||||||
"documents": [self.version.pk],
|
|
||||||
"addresses": "recipient@example.com",
|
|
||||||
"subject": "Version",
|
|
||||||
"message": "Version",
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
with (
|
|
||||||
mock.patch("documents.views.AIConfig") as ai_config,
|
|
||||||
mock.patch("documents.views.stream_chat_with_documents") as chat,
|
|
||||||
):
|
|
||||||
ai_config.return_value.ai_enabled = True
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/chat/",
|
|
||||||
{"q": "Version?", "document_id": self.version.pk},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
chat.assert_not_called()
|
|
||||||
self.assertTrue(Note.objects.filter(pk=note.pk).exists())
|
|
||||||
self.assertFalse(ShareLink.objects.exists())
|
|
||||||
|
|
||||||
@mock.patch("documents.views.build_share_link_bundle.apply_async")
|
|
||||||
def test_root_permissions_allow_sharing_a_private_version(self, build_mock):
|
|
||||||
self.version.owner = UserFactory()
|
|
||||||
self.version.save(update_fields=["owner"])
|
|
||||||
grant_object(self.user, self.root, "view_document", "change_document")
|
|
||||||
note = Note.objects.create(document=self.version, note="Version note")
|
|
||||||
response = self.client.get(f"/api/documents/{self.version.pk}/notes/")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(response.data[0]["id"], note.pk)
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/share_links/",
|
|
||||||
{"document": self.version.pk, "file_version": "original"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 201)
|
|
||||||
self.assertEqual(ShareLink.objects.get().document_id, self.version.pk)
|
|
||||||
response = self.client.get(f"/api/documents/{self.version.pk}/share_links/")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertEqual(len(response.data), 1)
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/share_link_bundles/",
|
|
||||||
{"document_ids": [self.version.pk], "file_version": "original"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 201)
|
|
||||||
build_mock.assert_called_once()
|
|
||||||
|
|
||||||
def test_root_view_permission_does_not_allow_note_changes(self):
|
|
||||||
grant_object(self.user, self.root, "view_document")
|
|
||||||
note = Note.objects.create(document=self.version, note="Version note")
|
|
||||||
response = self.client.get(f"/api/documents/{self.version.pk}/notes/")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
response = self.client.post(
|
|
||||||
f"/api/documents/{self.version.pk}/notes/",
|
|
||||||
{"note": "New note"},
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
response = self.client.delete(
|
|
||||||
f"/api/documents/{self.version.pk}/notes/?id={note.pk}",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
self.assertTrue(Note.objects.filter(pk=note.pk).exists())
|
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
|
||||||
def test_history_uses_root_ownership(self):
|
|
||||||
self.root.owner = self.user
|
|
||||||
self.root.save(update_fields=["owner"])
|
|
||||||
self.version.owner = UserFactory()
|
|
||||||
self.version.save(update_fields=["owner"])
|
|
||||||
response = self.client.get(f"/api/documents/{self.version.pk}/history/")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_selection_data_rejects_stale_version_ownership(self):
|
|
||||||
for owner in (None, self.user):
|
|
||||||
self.version.owner = owner
|
|
||||||
self.version.save(update_fields=["owner"])
|
|
||||||
with self.subTest(owner=owner):
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/selection_data/",
|
|
||||||
{"documents": [self.version.pk]},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
def test_selection_data_allows_private_version_of_permitted_root(self):
|
|
||||||
self.version.owner = UserFactory()
|
|
||||||
self.version.save(update_fields=["owner"])
|
|
||||||
grant_object(self.user, self.root, "view_document")
|
|
||||||
other = DocumentFactory(owner=self.user)
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/selection_data/",
|
|
||||||
{"documents": [self.version.pk, other.pk]},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
@@ -6,7 +6,6 @@ from rest_framework.test import APITestCase
|
|||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
from paperless_testing.permissions import grant_all_global
|
from paperless_testing.permissions import grant_all_global
|
||||||
|
|
||||||
@@ -280,53 +279,3 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
|||||||
Document.objects.filter(root_document=root).values_list("id", flat=True),
|
Document.objects.filter(root_document=root).values_list("id", flat=True),
|
||||||
[version.pk for version in versions],
|
[version.pk for version in versions],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_api_trash_version_follows_root_owner(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A deleted version of user2's document, owned by nobody
|
|
||||||
- A deleted version of the user's document, owned by user2
|
|
||||||
WHEN:
|
|
||||||
- The user lists the trash and tries to restore or empty the versions
|
|
||||||
THEN:
|
|
||||||
- Only the version of the user's own document is listed
|
|
||||||
- The other version can't be restored or emptied
|
|
||||||
- The version of the user's own document can be restored
|
|
||||||
"""
|
|
||||||
user2 = UserFactory(username="user2")
|
|
||||||
other_version = DocumentFactory(
|
|
||||||
root_document=DocumentFactory(owner=user2),
|
|
||||||
version_index=1,
|
|
||||||
)
|
|
||||||
other_version.delete()
|
|
||||||
own_version = DocumentFactory(
|
|
||||||
owner=user2,
|
|
||||||
root_document=DocumentFactory(owner=self.user),
|
|
||||||
version_index=1,
|
|
||||||
)
|
|
||||||
own_version.delete()
|
|
||||||
|
|
||||||
resp = self.client.get("/api/trash/")
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(
|
|
||||||
[doc["id"] for doc in resp.data["results"]],
|
|
||||||
[own_version.pk],
|
|
||||||
)
|
|
||||||
|
|
||||||
for action in ("restore", "empty"):
|
|
||||||
with self.subTest(action=action):
|
|
||||||
resp = self.client.post(
|
|
||||||
"/api/trash/",
|
|
||||||
{"action": action, "documents": [other_version.pk]},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
|
||||||
self.assertTrue(
|
|
||||||
Document.deleted_objects.filter(pk=other_version.pk).exists(),
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = self.client.post(
|
|
||||||
"/api/trash/",
|
|
||||||
{"action": "restore", "documents": [own_version.pk]},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertTrue(Document.objects.filter(pk=own_version.pk).exists())
|
|
||||||
@@ -4,7 +4,6 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import pytest
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@@ -13,7 +12,6 @@ from django.test import TestCase
|
|||||||
from django.test.utils import CaptureQueriesContext
|
from django.test.utils import CaptureQueriesContext
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from pytest_mock import MockerFixture
|
|
||||||
|
|
||||||
from documents import bulk_edit
|
from documents import bulk_edit
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
@@ -25,7 +23,6 @@ from documents.models import StoragePath
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_objects
|
from documents.permissions import set_permissions_for_objects
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
|
||||||
from paperless_testing.permissions import grant_object
|
from paperless_testing.permissions import grant_object
|
||||||
|
|
||||||
|
|
||||||
@@ -1973,22 +1970,18 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
self.assertIn("Error removing password from document", cm.output[0])
|
self.assertIn("Error removing password from document", cm.output[0])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
class TestBulkEditReprocess(DirectoriesMixin, TestCase):
|
||||||
class TestBulkEditReprocess:
|
def setUp(self) -> None:
|
||||||
@pytest.fixture
|
super().setUp()
|
||||||
def mock_task(self, mocker: MockerFixture) -> mock.MagicMock:
|
|
||||||
return mocker.patch(
|
self.doc = Document.objects.create(
|
||||||
"documents.bulk_edit.update_document_content_maybe_archive_file",
|
title="test",
|
||||||
|
checksum="A",
|
||||||
|
mime_type="application/pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file")
|
||||||
def _queued_ids(mock_task: mock.MagicMock) -> list[int]:
|
def test_reprocess_defaults_to_local(self, mock_task: mock.Mock) -> None:
|
||||||
return [
|
|
||||||
call.kwargs["kwargs"]["document_id"]
|
|
||||||
for call in mock_task.apply_async.call_args_list
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_reprocess_defaults_to_local(self, mock_task: mock.MagicMock) -> None:
|
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A reprocess request that says nothing about remote OCR
|
- A reprocess request that says nothing about remote OCR
|
||||||
@@ -1997,17 +1990,18 @@ class TestBulkEditReprocess:
|
|||||||
THEN:
|
THEN:
|
||||||
- The task is queued without asking for the remote engine
|
- The task is queued without asking for the remote engine
|
||||||
"""
|
"""
|
||||||
doc = DocumentFactory()
|
result = bulk_edit.reprocess([self.doc.id])
|
||||||
|
|
||||||
assert bulk_edit.reprocess([doc.id]) == "OK"
|
|
||||||
|
|
||||||
|
self.assertEqual(result, "OK")
|
||||||
mock_task.apply_async.assert_called_once()
|
mock_task.apply_async.assert_called_once()
|
||||||
assert mock_task.apply_async.call_args.kwargs["kwargs"] == {
|
_, kwargs = mock_task.apply_async.call_args
|
||||||
"document_id": doc.id,
|
self.assertEqual(
|
||||||
"remote_ocr": False,
|
kwargs["kwargs"],
|
||||||
}
|
{"document_id": self.doc.id, "remote_ocr": False},
|
||||||
|
)
|
||||||
|
|
||||||
def test_reprocess_passes_remote_ocr(self, mock_task: mock.MagicMock) -> None:
|
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file")
|
||||||
|
def test_reprocess_passes_remote_ocr(self, mock_task: mock.Mock) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- A reprocess request that explicitly asks for remote OCR
|
- A reprocess request that explicitly asks for remote OCR
|
||||||
@@ -2016,72 +2010,14 @@ class TestBulkEditReprocess:
|
|||||||
THEN:
|
THEN:
|
||||||
- The request is forwarded to the task for every document
|
- The request is forwarded to the task for every document
|
||||||
"""
|
"""
|
||||||
docs = DocumentFactory.create_batch(2)
|
other = Document.objects.create(
|
||||||
|
title="test2",
|
||||||
bulk_edit.reprocess([doc.id for doc in docs], remote_ocr=True)
|
checksum="B",
|
||||||
|
mime_type="application/pdf",
|
||||||
assert mock_task.apply_async.call_count == 2
|
|
||||||
for call in mock_task.apply_async.call_args_list:
|
|
||||||
assert call.kwargs["kwargs"]["remote_ocr"]
|
|
||||||
|
|
||||||
def test_reprocess_root_uses_latest_version(
|
|
||||||
self,
|
|
||||||
mock_task: mock.MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document with two versions
|
|
||||||
WHEN:
|
|
||||||
- reprocess is called with the root document
|
|
||||||
THEN:
|
|
||||||
- The latest version is reprocessed, not the root's original file
|
|
||||||
"""
|
|
||||||
root = DocumentFactory()
|
|
||||||
DocumentFactory(root_document=root, version_index=1)
|
|
||||||
latest = DocumentFactory(root_document=root, version_index=2)
|
|
||||||
|
|
||||||
bulk_edit.reprocess([root.id])
|
|
||||||
|
|
||||||
assert self._queued_ids(mock_task) == [latest.id]
|
|
||||||
|
|
||||||
def test_reprocess_explicit_version(self, mock_task: mock.MagicMock) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document with two versions
|
|
||||||
WHEN:
|
|
||||||
- reprocess is called with the older version
|
|
||||||
THEN:
|
|
||||||
- That version is reprocessed
|
|
||||||
"""
|
|
||||||
root = DocumentFactory()
|
|
||||||
older = DocumentFactory(root_document=root, version_index=1)
|
|
||||||
DocumentFactory(root_document=root, version_index=2)
|
|
||||||
|
|
||||||
bulk_edit.reprocess([older.id])
|
|
||||||
|
|
||||||
assert self._queued_ids(mock_task) == [older.id]
|
|
||||||
|
|
||||||
def test_reprocess_root_and_latest_version_dispatches_once(
|
|
||||||
self,
|
|
||||||
mock_task: mock.MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document with two versions, the latest created on a
|
|
||||||
different date than the root
|
|
||||||
WHEN:
|
|
||||||
- reprocess is called with both the root and its latest version
|
|
||||||
THEN:
|
|
||||||
- The latest version is reprocessed only once
|
|
||||||
"""
|
|
||||||
root = DocumentFactory(created=date(2024, 1, 1))
|
|
||||||
DocumentFactory(root_document=root, version_index=1)
|
|
||||||
latest = DocumentFactory(
|
|
||||||
root_document=root,
|
|
||||||
version_index=2,
|
|
||||||
created=date(2025, 1, 1),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
bulk_edit.reprocess([root.id, latest.id])
|
bulk_edit.reprocess([self.doc.id, other.id], remote_ocr=True)
|
||||||
|
|
||||||
assert self._queued_ids(mock_task) == [latest.id]
|
self.assertEqual(mock_task.apply_async.call_count, 2)
|
||||||
|
for call in mock_task.apply_async.call_args_list:
|
||||||
|
self.assertTrue(call.kwargs["kwargs"]["remote_ocr"])
|
||||||
@@ -90,13 +90,10 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("document_ids", response.data)
|
self.assertIn("document_ids", response.data)
|
||||||
|
|
||||||
def test_create_bundle_rejects_insufficient_permissions(self) -> None:
|
@mock.patch("documents.views.permitted_document_ids", return_value=set())
|
||||||
requester = UserFactory(username="bundle_creator")
|
def test_create_bundle_rejects_insufficient_permissions(self, perms_mock) -> None:
|
||||||
grant_global(requester, "add_sharelinkbundle", "view_document")
|
|
||||||
self.client.force_authenticate(requester)
|
|
||||||
document = DocumentFactory(owner=UserFactory(username="document_owner"))
|
|
||||||
payload = {
|
payload = {
|
||||||
"document_ids": [self.document.pk, document.pk],
|
"document_ids": [self.document.pk],
|
||||||
"file_version": ShareLink.FileVersion.ARCHIVE,
|
"file_version": ShareLink.FileVersion.ARCHIVE,
|
||||||
"expiration_days": 7,
|
"expiration_days": 7,
|
||||||
}
|
}
|
||||||
@@ -104,8 +101,8 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
response = self.client.post(self.ENDPOINT, payload, format="json")
|
response = self.client.post(self.ENDPOINT, payload, format="json")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(str(document.pk), str(response.data["document_ids"]))
|
self.assertIn("document_ids", response.data)
|
||||||
self.assertFalse(ShareLinkBundle.objects.exists())
|
perms_mock.assert_called()
|
||||||
|
|
||||||
@mock.patch("documents.views.build_share_link_bundle.apply_async")
|
@mock.patch("documents.views.build_share_link_bundle.apply_async")
|
||||||
def test_rebuild_bundle_resets_state(self, delay_mock) -> None:
|
def test_rebuild_bundle_resets_state(self, delay_mock) -> None:
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from documents.sanity_checker import SanityCheckMessages
|
|||||||
from documents.tests.helpers import dummy_preprocess
|
from documents.tests.helpers import dummy_preprocess
|
||||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||||
from paperless_testing.dirs import DirectoriesMixin
|
from paperless_testing.dirs import DirectoriesMixin
|
||||||
from paperless_testing.factories import DocumentFactory
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -288,82 +287,6 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
|||||||
tasks.update_document_content_maybe_archive_file(doc.pk)
|
tasks.update_document_content_maybe_archive_file(doc.pk)
|
||||||
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
||||||
|
|
||||||
def _create_root_with_version(self) -> tuple[Document, Document]:
|
|
||||||
sample1 = self.dirs.scratch_dir / "sample.pdf"
|
|
||||||
shutil.copy(
|
|
||||||
Path(__file__).parent
|
|
||||||
/ "samples"
|
|
||||||
/ "documents"
|
|
||||||
/ "originals"
|
|
||||||
/ "0000001.pdf",
|
|
||||||
sample1,
|
|
||||||
)
|
|
||||||
root = DocumentFactory(content="root content", mime_type="application/pdf")
|
|
||||||
version = DocumentFactory(
|
|
||||||
content="my document",
|
|
||||||
filename=sample1,
|
|
||||||
mime_type="application/pdf",
|
|
||||||
root_document=root,
|
|
||||||
version_index=1,
|
|
||||||
)
|
|
||||||
return root, version
|
|
||||||
|
|
||||||
@mock.patch("documents.tasks.clear_document_caches")
|
|
||||||
@mock.patch("documents.search.get_backend")
|
|
||||||
def test_update_content_version_indexes_root(
|
|
||||||
self,
|
|
||||||
mock_get_backend: mock.Mock,
|
|
||||||
mock_clear_caches: mock.Mock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document with a version
|
|
||||||
WHEN:
|
|
||||||
- Update content task is called for the version
|
|
||||||
THEN:
|
|
||||||
- The version's content is updated
|
|
||||||
- The root document is indexed rather than the version
|
|
||||||
- Caches are cleared for both
|
|
||||||
"""
|
|
||||||
root, version = self._create_root_with_version()
|
|
||||||
|
|
||||||
tasks.update_document_content_maybe_archive_file(version.pk)
|
|
||||||
|
|
||||||
self.assertNotEqual(
|
|
||||||
Document.objects.get(pk=version.pk).content,
|
|
||||||
"my document",
|
|
||||||
)
|
|
||||||
self.assertEqual(Document.objects.get(pk=root.pk).content, "root content")
|
|
||||||
indexed = mock_get_backend.return_value.add_or_update.call_args.args[0]
|
|
||||||
self.assertEqual(indexed.pk, root.pk)
|
|
||||||
mock_clear_caches.assert_has_calls(
|
|
||||||
[mock.call(version.pk), mock.call(root.pk)],
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface")
|
|
||||||
@mock.patch("documents.tasks.llm_index_add_or_update_document")
|
|
||||||
@mock.patch("documents.search.get_backend")
|
|
||||||
def test_update_content_version_updates_llm_index_for_root(
|
|
||||||
self,
|
|
||||||
mock_get_backend: mock.Mock,
|
|
||||||
mock_llm_index: mock.Mock,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A root document with a version
|
|
||||||
- The LLM index is enabled
|
|
||||||
WHEN:
|
|
||||||
- Update content task is called for the version
|
|
||||||
THEN:
|
|
||||||
- The LLM index is updated for the root document, not the version
|
|
||||||
"""
|
|
||||||
root, version = self._create_root_with_version()
|
|
||||||
|
|
||||||
tasks.update_document_content_maybe_archive_file(version.pk)
|
|
||||||
|
|
||||||
mock_llm_index.assert_called_once()
|
|
||||||
self.assertEqual(mock_llm_index.call_args.args[0].pk, root.pk)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
|
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+41
-81
@@ -174,7 +174,6 @@ from documents.permissions import TrashPermissions
|
|||||||
from documents.permissions import ViewDocumentsPermissions
|
from documents.permissions import ViewDocumentsPermissions
|
||||||
from documents.permissions import annotate_document_count_by_ids
|
from documents.permissions import annotate_document_count_by_ids
|
||||||
from documents.permissions import annotate_document_count_for_related_queryset
|
from documents.permissions import annotate_document_count_for_related_queryset
|
||||||
from documents.permissions import documents_without_permitted_root
|
|
||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
from documents.permissions import has_global_statistics_permission
|
from documents.permissions import has_global_statistics_permission
|
||||||
@@ -1551,16 +1550,13 @@ class DocumentViewSet(
|
|||||||
)
|
)
|
||||||
def suggestions(self, request, pk=None):
|
def suggestions(self, request, pk=None):
|
||||||
doc = get_object_or_404(
|
doc = get_object_or_404(
|
||||||
Document.objects.select_related(
|
Document.objects.select_related("owner").prefetch_related("versions"),
|
||||||
"owner",
|
|
||||||
"root_document__owner",
|
|
||||||
).prefetch_related("versions"),
|
|
||||||
pk=pk,
|
pk=pk,
|
||||||
)
|
)
|
||||||
if request.user is not None and not has_perms_owner_aware(
|
if request.user is not None and not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -1614,16 +1610,13 @@ class DocumentViewSet(
|
|||||||
@method_decorator(cache_control(no_cache=True))
|
@method_decorator(cache_control(no_cache=True))
|
||||||
def ai_suggestions(self, request, pk=None):
|
def ai_suggestions(self, request, pk=None):
|
||||||
doc = get_object_or_404(
|
doc = get_object_or_404(
|
||||||
Document.objects.select_related(
|
Document.objects.select_related("owner").prefetch_related("versions"),
|
||||||
"owner",
|
|
||||||
"root_document__owner",
|
|
||||||
).prefetch_related("versions"),
|
|
||||||
pk=pk,
|
pk=pk,
|
||||||
)
|
)
|
||||||
if request.user is not None and not has_perms_owner_aware(
|
if request.user is not None and not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -1863,20 +1856,15 @@ class DocumentViewSet(
|
|||||||
currentUser = request.user
|
currentUser = request.user
|
||||||
try:
|
try:
|
||||||
doc = (
|
doc = (
|
||||||
Document.objects.select_related("owner", "root_document__owner")
|
Document.objects.select_related("owner")
|
||||||
.prefetch_related("notes")
|
.prefetch_related("notes")
|
||||||
.only(
|
.only("pk", "owner__id")
|
||||||
"pk",
|
|
||||||
"owner__id",
|
|
||||||
"root_document__id",
|
|
||||||
"root_document__owner__id",
|
|
||||||
)
|
|
||||||
.get(pk=pk)
|
.get(pk=pk)
|
||||||
)
|
)
|
||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"view_document",
|
"view_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions to view notes")
|
return HttpResponseForbidden("Insufficient permissions to view notes")
|
||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist:
|
||||||
@@ -1898,7 +1886,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Insufficient permissions to create notes",
|
"Insufficient permissions to create notes",
|
||||||
@@ -1941,7 +1929,7 @@ class DocumentViewSet(
|
|||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions to delete notes")
|
return HttpResponseForbidden("Insufficient permissions to delete notes")
|
||||||
|
|
||||||
@@ -1985,13 +1973,11 @@ class DocumentViewSet(
|
|||||||
def share_links(self, request, pk=None):
|
def share_links(self, request, pk=None):
|
||||||
currentUser = request.user
|
currentUser = request.user
|
||||||
try:
|
try:
|
||||||
doc = Document.objects.select_related("owner", "root_document__owner").get(
|
doc = Document.objects.select_related("owner").get(pk=pk)
|
||||||
pk=pk,
|
|
||||||
)
|
|
||||||
if currentUser is not None and not has_perms_owner_aware(
|
if currentUser is not None and not has_perms_owner_aware(
|
||||||
currentUser,
|
currentUser,
|
||||||
"change_document",
|
"change_document",
|
||||||
get_root_document(doc),
|
doc,
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Insufficient permissions to add share link",
|
"Insufficient permissions to add share link",
|
||||||
@@ -2022,11 +2008,10 @@ class DocumentViewSet(
|
|||||||
if not settings.AUDIT_LOG_ENABLED:
|
if not settings.AUDIT_LOG_ENABLED:
|
||||||
return HttpResponseBadRequest("Audit log is disabled")
|
return HttpResponseBadRequest("Audit log is disabled")
|
||||||
try:
|
try:
|
||||||
doc = Document.objects.select_related("root_document__owner").get(pk=pk)
|
doc = Document.objects.get(pk=pk)
|
||||||
root_doc = get_root_document(doc)
|
|
||||||
if not request.user.has_perm("auditlog.view_logentry") or (
|
if not request.user.has_perm("auditlog.view_logentry") or (
|
||||||
root_doc.owner is not None
|
doc.owner is not None
|
||||||
and root_doc.owner != request.user
|
and doc.owner != request.user
|
||||||
and not request.user.is_superuser
|
and not request.user.is_superuser
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
@@ -2117,7 +2102,9 @@ class DocumentViewSet(
|
|||||||
documents = Document.objects.filter(pk__in=document_ids)
|
documents = Document.objects.filter(pk__in=document_ids)
|
||||||
if (
|
if (
|
||||||
request.user is not None
|
request.user is not None
|
||||||
and documents_without_permitted_root(documents, request.user).exists()
|
and documents.exclude(
|
||||||
|
pk__in=permitted_document_ids(request.user),
|
||||||
|
).exists()
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
@@ -2443,17 +2430,11 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
|
|
||||||
if doc_id:
|
if doc_id:
|
||||||
try:
|
try:
|
||||||
document = Document.objects.select_related(
|
document = Document.objects.get(id=doc_id)
|
||||||
"root_document__owner",
|
|
||||||
).get(id=doc_id)
|
|
||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist:
|
||||||
return HttpResponseBadRequest("Document not found")
|
return HttpResponseBadRequest("Document not found")
|
||||||
|
|
||||||
if not has_perms_owner_aware(
|
if not has_perms_owner_aware(request.user, "view_document", document):
|
||||||
request.user,
|
|
||||||
"view_document",
|
|
||||||
get_root_document(document),
|
|
||||||
):
|
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
documents = Document.objects.filter(pk=document.pk)
|
documents = Document.objects.filter(pk=document.pk)
|
||||||
@@ -3624,11 +3605,10 @@ class SelectionDataView(DocumentSelectionMixin, GenericAPIView[Any]):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
validated_data=serializer.validated_data,
|
validated_data=serializer.validated_data,
|
||||||
)
|
)
|
||||||
documents = Document.objects.filter(pk__in=ids)
|
permitted_documents = Document.objects.filter(
|
||||||
if (
|
id__in=permitted_document_ids(request.user),
|
||||||
documents.count() != len(ids)
|
)
|
||||||
or documents_without_permitted_root(documents, request.user).exists()
|
if permitted_documents.filter(pk__in=ids).count() != len(ids):
|
||||||
):
|
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
correspondents = Correspondent.objects.annotate(
|
correspondents = Correspondent.objects.annotate(
|
||||||
@@ -4810,23 +4790,19 @@ class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
denied_id = (
|
|
||||||
documents_without_permitted_root(documents_qs, request.user)
|
|
||||||
.order_by("pk")
|
|
||||||
.values_list("pk", flat=True)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if denied_id is not None:
|
|
||||||
raise ValidationError(
|
|
||||||
{
|
|
||||||
"document_ids": _(
|
|
||||||
"Insufficient permissions to share document %(id)s.",
|
|
||||||
)
|
|
||||||
% {"id": denied_id},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
documents = list(documents_qs)
|
documents = list(documents_qs)
|
||||||
|
permitted_ids = set(permitted_document_ids(request.user))
|
||||||
|
for document in documents:
|
||||||
|
if document.pk not in permitted_ids:
|
||||||
|
raise ValidationError(
|
||||||
|
{
|
||||||
|
"document_ids": _(
|
||||||
|
"Insufficient permissions to share document %(id)s.",
|
||||||
|
)
|
||||||
|
% {"id": document.pk},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
document_map = {document.pk: document for document in documents}
|
document_map = {document.pk: document for document in documents}
|
||||||
ordered_documents = [document_map[doc_id] for doc_id in document_ids]
|
ordered_documents = [document_map[doc_id] for doc_id in document_ids]
|
||||||
|
|
||||||
@@ -5611,23 +5587,6 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
class _TrashPermittedObjectsFilter(PermittedObjectsFilter):
|
||||||
include_granted = False
|
include_granted = False
|
||||||
|
|
||||||
def filter_queryset(self, request, queryset, view):
|
|
||||||
if request.user.is_superuser or not request.user.is_active:
|
|
||||||
return super().filter_queryset(request, queryset, view)
|
|
||||||
|
|
||||||
# A version belongs to whoever owns its root
|
|
||||||
def owned_or_unowned(prefix: str) -> Q:
|
|
||||||
return Q(**{f"{prefix}owner": request.user}) | Q(
|
|
||||||
**{f"{prefix}owner__isnull": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
return queryset.filter(
|
|
||||||
(Q(root_document__isnull=True) & owned_or_unowned(""))
|
|
||||||
| (
|
|
||||||
Q(root_document__isnull=False) & owned_or_unowned("root_document__")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
filter_backends = (_TrashPermittedObjectsFilter,)
|
filter_backends = (_TrashPermittedObjectsFilter,)
|
||||||
pagination_class = StandardPagination
|
pagination_class = StandardPagination
|
||||||
|
|
||||||
@@ -5657,11 +5616,12 @@ class TrashView(ListModelMixin, PassUserMixin):
|
|||||||
if doc_ids is not None
|
if doc_ids is not None
|
||||||
else self.filter_queryset(self.get_queryset()).all()
|
else self.filter_queryset(self.get_queryset()).all()
|
||||||
)
|
)
|
||||||
if documents_without_permitted_root(
|
if docs.exclude(
|
||||||
docs,
|
pk__in=permitted_document_ids(
|
||||||
request.user,
|
request.user,
|
||||||
perm="delete_document",
|
perm="delete_document",
|
||||||
include_deleted=True,
|
include_deleted=True,
|
||||||
|
),
|
||||||
).exists():
|
).exists():
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
action = serializer.validated_data.get("action")
|
action = serializer.validated_data.get("action")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-10-08 18:39+0000\n"
|
"POT-Creation-Date: 2026-10-06 15:12+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1652,49 +1652,49 @@ msgstr ""
|
|||||||
msgid "workflow runs"
|
msgid "workflow runs"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:516 documents/serialisers.py:873
|
#: documents/serialisers.py:515 documents/serialisers.py:872
|
||||||
#: documents/serialisers.py:2903 documents/views.py:344 documents/views.py:2751
|
#: documents/serialisers.py:2902 documents/views.py:343 documents/views.py:2732
|
||||||
#: paperless_mail/serialisers.py:156
|
#: paperless_mail/serialisers.py:156
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:709
|
#: documents/serialisers.py:708
|
||||||
msgid "Invalid color."
|
msgid "Invalid color."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2370
|
#: documents/serialisers.py:2369
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "File type %(type)s not supported"
|
msgid "File type %(type)s not supported"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2414
|
#: documents/serialisers.py:2413
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field id must be an integer: %(id)s"
|
msgid "Custom field id must be an integer: %(id)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2421
|
#: documents/serialisers.py:2420
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Custom field with id %(id)s does not exist"
|
msgid "Custom field with id %(id)s does not exist"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2438 documents/serialisers.py:2448
|
#: documents/serialisers.py:2437 documents/serialisers.py:2447
|
||||||
msgid ""
|
msgid ""
|
||||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2443
|
#: documents/serialisers.py:2442
|
||||||
msgid "Some custom fields don't exist or were specified twice."
|
msgid "Some custom fields don't exist or were specified twice."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2590
|
#: documents/serialisers.py:2589
|
||||||
msgid "Invalid variable detected."
|
msgid "Invalid variable detected."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2959
|
#: documents/serialisers.py:2958
|
||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2989 documents/views.py:4807
|
#: documents/serialisers.py:2988 documents/views.py:4787
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1945,40 +1945,40 @@ msgstr ""
|
|||||||
msgid ", "
|
msgid ", "
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:337 documents/views.py:2748
|
#: documents/views.py:336 documents/views.py:2729
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1683
|
#: documents/views.py:1676
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1694
|
#: documents/views.py:1687
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1706
|
#: documents/views.py:1699
|
||||||
msgid "AI backend rejected the request. Check logs for details."
|
msgid "AI backend rejected the request. Check logs for details."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2573 documents/views.py:2889
|
#: documents/views.py:2554 documents/views.py:2870
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4823
|
#: documents/views.py:4800
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4870
|
#: documents/views.py:4846
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4934
|
#: documents/views.py:4910
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4948
|
#: documents/views.py:4924
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from urllib.parse import quote
|
|||||||
from allauth.account.adapter import DefaultAccountAdapter
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
from allauth.core import context
|
from allauth.core import context
|
||||||
from allauth.headless.tokens.strategies.sessions import SessionTokenStrategy
|
from allauth.headless.tokens.strategies.sessions import SessionTokenStrategy
|
||||||
from allauth.mfa.stages import AuthenticateStage
|
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
@@ -20,34 +19,7 @@ from paperless.signals import handle_social_account_updated
|
|||||||
logger = logging.getLogger("paperless.auth")
|
logger = logging.getLogger("paperless.auth")
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountAuthenticateStage(AuthenticateStage):
|
|
||||||
"""
|
|
||||||
Skips the 2FA prompt for a social login if the ID token's `amr` claim shows the
|
|
||||||
identity provider already used one of the SOCIAL_ACCOUNT_MFA_TRUSTED_AMR methods.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _should_handle(self, request: HttpRequest) -> bool:
|
|
||||||
sociallogin = (self.login.signal_kwargs or {}).get("sociallogin")
|
|
||||||
if sociallogin is not None and settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR:
|
|
||||||
id_token = (sociallogin.account.extra_data or {}).get("id_token") or {}
|
|
||||||
amr = id_token.get("amr") or []
|
|
||||||
if set(amr) & set(settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR):
|
|
||||||
logger.debug(
|
|
||||||
f"Skipping 2FA for `{self.login.user}`, provider `{sociallogin.account.provider}` reported amr {amr}",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return super()._should_handle(request)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||||
def get_login_stages(self) -> list[str]:
|
|
||||||
return [
|
|
||||||
"paperless.adapter.SocialAccountAuthenticateStage"
|
|
||||||
if stage == "allauth.mfa.stages.AuthenticateStage"
|
|
||||||
else stage
|
|
||||||
for stage in super().get_login_stages()
|
|
||||||
]
|
|
||||||
|
|
||||||
def is_open_for_signup(self, request):
|
def is_open_for_signup(self, request):
|
||||||
"""
|
"""
|
||||||
Check whether the site is open for signups, which can be
|
Check whether the site is open for signups, which can be
|
||||||
|
|||||||
@@ -348,11 +348,6 @@ SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP: Final[str | None] = os.getenv(
|
|||||||
SOCIAL_ACCOUNT_SYNC_STAFF_GROUP: Final[str | None] = os.getenv(
|
SOCIAL_ACCOUNT_SYNC_STAFF_GROUP: Final[str | None] = os.getenv(
|
||||||
"PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP",
|
"PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP",
|
||||||
)
|
)
|
||||||
SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = [
|
|
||||||
value.strip()
|
|
||||||
for value in os.getenv("PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR", "mfa").split(",")
|
|
||||||
if value.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy"
|
HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy"
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,19 @@ import logging
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from allauth.account.adapter import get_adapter
|
from allauth.account.adapter import get_adapter
|
||||||
from allauth.account.models import Login
|
|
||||||
from allauth.core import context
|
from allauth.core import context
|
||||||
from allauth.mfa.totp.internal.auth import TOTP
|
|
||||||
from allauth.mfa.totp.internal.auth import generate_totp_secret
|
|
||||||
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
|
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
|
||||||
from allauth.socialaccount.models import SocialAccount
|
|
||||||
from allauth.socialaccount.models import SocialLogin
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.test import RequestFactory
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from paperless.adapter import DrfTokenStrategy
|
from paperless.adapter import DrfTokenStrategy
|
||||||
from paperless.adapter import SocialAccountAuthenticateStage
|
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
@@ -136,89 +129,6 @@ class TestCustomAccountAdapter:
|
|||||||
assert not user2.is_superuser
|
assert not user2.is_superuser
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestSocialAccountAuthenticateStage:
|
|
||||||
@staticmethod
|
|
||||||
def _should_handle(user: User, extra_data: dict | None) -> bool:
|
|
||||||
signal_kwargs = None
|
|
||||||
if extra_data is not None:
|
|
||||||
account = SocialAccount(
|
|
||||||
provider="openid_connect",
|
|
||||||
uid="uid",
|
|
||||||
extra_data=extra_data,
|
|
||||||
)
|
|
||||||
signal_kwargs = {"sociallogin": SocialLogin(user=user, account=account)}
|
|
||||||
request = RequestFactory().get("/")
|
|
||||||
request.session = {}
|
|
||||||
stage = SocialAccountAuthenticateStage(
|
|
||||||
None,
|
|
||||||
request,
|
|
||||||
Login(user=user, signal_kwargs=signal_kwargs),
|
|
||||||
)
|
|
||||||
return stage._should_handle(request)
|
|
||||||
|
|
||||||
def test_stage_replaces_allauth_stage(self) -> None:
|
|
||||||
stages = get_adapter().get_login_stages()
|
|
||||||
|
|
||||||
assert "paperless.adapter.SocialAccountAuthenticateStage" in stages
|
|
||||||
assert "allauth.mfa.stages.AuthenticateStage" not in stages
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("trusted_amr", "extra_data", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
["mfa"],
|
|
||||||
{"id_token": {"amr": ["pwd", "mfa"]}},
|
|
||||||
False,
|
|
||||||
id="trusted-amr",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
["phr"],
|
|
||||||
{"id_token": {"amr": ["otp"]}},
|
|
||||||
True,
|
|
||||||
id="untrusted-amr",
|
|
||||||
),
|
|
||||||
pytest.param([], {"id_token": {"amr": ["mfa"]}}, True, id="setting-unset"),
|
|
||||||
pytest.param(["mfa"], {"id_token": {}}, True, id="no-amr-claim"),
|
|
||||||
pytest.param(
|
|
||||||
["mfa"],
|
|
||||||
{"userinfo": {"amr": ["mfa"]}},
|
|
||||||
True,
|
|
||||||
id="userinfo-only",
|
|
||||||
),
|
|
||||||
pytest.param(["mfa"], None, True, id="regular-login"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_mfa_skipped_only_for_trusted_amr(
|
|
||||||
self,
|
|
||||||
settings: Settings,
|
|
||||||
trusted_amr: list[str],
|
|
||||||
extra_data: dict | None,
|
|
||||||
*,
|
|
||||||
expected: bool,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A user with TOTP enabled
|
|
||||||
WHEN:
|
|
||||||
- The user logs in, via a social account or not
|
|
||||||
THEN:
|
|
||||||
- The 2FA prompt is only skipped if the ID token's amr claim contains
|
|
||||||
one of SOCIAL_ACCOUNT_MFA_TRUSTED_AMR
|
|
||||||
"""
|
|
||||||
settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = trusted_amr
|
|
||||||
user = UserFactory(username="testuser")
|
|
||||||
TOTP.activate(user, generate_totp_secret())
|
|
||||||
|
|
||||||
assert self._should_handle(user, extra_data) is expected
|
|
||||||
|
|
||||||
def test_no_mfa_for_user_without_authenticator(self, settings: Settings) -> None:
|
|
||||||
settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = ["mfa"]
|
|
||||||
user = UserFactory(username="testuser")
|
|
||||||
|
|
||||||
assert not self._should_handle(user, {"id_token": {"amr": ["pwd"]}})
|
|
||||||
|
|
||||||
|
|
||||||
class TestCustomSocialAccountAdapter:
|
class TestCustomSocialAccountAdapter:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_is_open_for_signup(self, settings: Settings) -> None:
|
def test_is_open_for_signup(self, settings: Settings) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user