mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-22 02:33:19 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a34318e3 | ||
|
|
bbcd6af2fe | ||
|
|
0431939f18 | ||
|
|
bed95ea301 | ||
|
|
42034c3c77 | ||
|
|
705220fb5a | ||
|
|
a424dace43 | ||
|
|
751299895e |
@@ -1215,7 +1215,7 @@ should be a valid crontab(5) expression describing when to run.
|
||||
|
||||
: If set to the string "disable", no emails will be fetched automatically.
|
||||
|
||||
Defaults to `*/10 * * * *` or every ten minutes.
|
||||
Defaults to every ten minutes, with an installation-specific minute offset.
|
||||
|
||||
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
|
||||
|
||||
|
||||
+26
-26
@@ -2195,7 +2195,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">64,65</context>
|
||||
<context context-type="linenumber">67,68</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2134950584701094962" datatype="html">
|
||||
@@ -6180,7 +6180,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">35,36</context>
|
||||
<context context-type="linenumber">38,39</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5554528553553249088" datatype="html">
|
||||
@@ -8361,11 +8361,11 @@
|
||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2023</context>
|
||||
<context context-type="linenumber">2025</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2029</context>
|
||||
<context context-type="linenumber">2031</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4958946940233632319" datatype="html">
|
||||
@@ -8407,148 +8407,148 @@
|
||||
<source>Label</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">10,11</context>
|
||||
<context context-type="linenumber">13,14</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3915966149686975421" datatype="html">
|
||||
<source>Optional</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">17,18</context>
|
||||
<context context-type="linenumber">20,21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3009569956897708604" datatype="html">
|
||||
<source>Upload a new version</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">32,33</context>
|
||||
<context context-type="linenumber">35,36</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="278763911843514709" datatype="html">
|
||||
<source>Use an existing document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">41,42</context>
|
||||
<context context-type="linenumber">44,45</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1906507263389090131" datatype="html">
|
||||
<source>Existing</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">44,45</context>
|
||||
<context context-type="linenumber">47,48</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3119565892291077820" datatype="html">
|
||||
<source>Uploading version...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">52,53</context>
|
||||
<context context-type="linenumber">55,56</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8538204518127762311" datatype="html">
|
||||
<source>Processing version...</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">58,59</context>
|
||||
<context context-type="linenumber">61,62</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6587183611950398104" datatype="html">
|
||||
<source>Version upload failed.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">63,64</context>
|
||||
<context context-type="linenumber">66,67</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6328986243040954523" datatype="html">
|
||||
<source>Version label</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">88,89</context>
|
||||
<context context-type="linenumber">91,92</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">98,99</context>
|
||||
<context context-type="linenumber">101,102</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2724055831234181057" datatype="html">
|
||||
<source>Version</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">109</context>
|
||||
<context context-type="linenumber">112</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2491751767883178298" datatype="html">
|
||||
<source>Delete this version?</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">140,141</context>
|
||||
<context context-type="linenumber">143,144</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="894108684749537200" datatype="html">
|
||||
<source>Delete version</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||
<context context-type="linenumber">145,146</context>
|
||||
<context context-type="linenumber">149,150</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9111284310237278846" datatype="html">
|
||||
<source>Error deleting version</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">158</context>
|
||||
<context context-type="linenumber">164</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1450293275912542759" datatype="html">
|
||||
<source>Error updating version label</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">191</context>
|
||||
<context context-type="linenumber">197</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3567158019134759286" datatype="html">
|
||||
<source>Uploading new version. Processing will happen in the background.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">213</context>
|
||||
<context context-type="linenumber">219</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1549684689040001385" datatype="html">
|
||||
<source>Missing task ID.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">226</context>
|
||||
<context context-type="linenumber">232</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="205445381296663832" datatype="html">
|
||||
<source>Upload failed.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">248</context>
|
||||
<context context-type="linenumber">254</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">272</context>
|
||||
<context context-type="linenumber">278</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8350861980330256599" datatype="html">
|
||||
<source>Error uploading new version</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">275</context>
|
||||
<context context-type="linenumber">281</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4050545381573552340" datatype="html">
|
||||
<source>Existing document added as a version.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">318</context>
|
||||
<context context-type="linenumber">324</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8552490241798412439" datatype="html">
|
||||
<source>Error adding existing document as a version</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">323</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6299008920007331381" datatype="html">
|
||||
|
||||
@@ -2171,6 +2171,11 @@ describe('DocumentDetailComponent', () => {
|
||||
).toBe(10)
|
||||
component.openEmailDocument()
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(
|
||||
(
|
||||
modalSpy.mock.results[1].value as NgbModalRef
|
||||
).componentInstance.documentIds()
|
||||
).toEqual([10])
|
||||
})
|
||||
|
||||
it('should set previewText', () => {
|
||||
|
||||
@@ -1973,7 +1973,9 @@ export class DocumentDetailComponent
|
||||
const modal = this.modalService.open(EmailDocumentDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.documentIds.set([this.document().id])
|
||||
modal.componentInstance.documentIds.set([
|
||||
this.selectedVersionId() ?? this.document().id,
|
||||
])
|
||||
modal.componentInstance.hasArchiveVersion.set(
|
||||
this.metadata()?.has_archive_version ??
|
||||
!!this.document()?.archived_file_name
|
||||
|
||||
+4
@@ -2,6 +2,9 @@
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle>
|
||||
<i-bs name="file-earmark-diff"></i-bs>
|
||||
<span class="d-none d-lg-inline ps-1" i18n>Versions</span>
|
||||
@if (versions.length > 1) {
|
||||
<span class="badge text-bg-secondary ms-1">{{ versions.length }}</span>
|
||||
}
|
||||
</button>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
<div class="px-3 py-2 mb-2">
|
||||
@@ -141,6 +144,7 @@
|
||||
i18n-confirmMessage
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
(confirm)="deleteVersion(version.id)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete version</span>
|
||||
</pngx-confirm-button>
|
||||
|
||||
+41
@@ -1,10 +1,16 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { SimpleChange, signal } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -31,6 +37,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||
let permissionsService: jest.Mocked<
|
||||
Pick<PermissionsService, 'currentUserCan'>
|
||||
>
|
||||
|
||||
beforeEach(async () => {
|
||||
finished$ = new Subject<{ taskId: string }>()
|
||||
@@ -47,6 +56,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
showError: jest.fn(),
|
||||
showInfo: jest.fn(),
|
||||
}
|
||||
permissionsService = {
|
||||
currentUserCan: jest.fn().mockReturnValue(true),
|
||||
}
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
@@ -73,6 +85,10 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
provide: NgbModal,
|
||||
useValue: modalService,
|
||||
},
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: permissionsService,
|
||||
},
|
||||
{
|
||||
provide: WebsocketStatusService,
|
||||
useValue: {
|
||||
@@ -143,6 +159,31 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show version delete buttons without document delete permission', () => {
|
||||
fixture.destroy()
|
||||
permissionsService.currentUserCan.mockReturnValue(false)
|
||||
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
component.documentId = 3
|
||||
component.selectedVersionId = 3
|
||||
component.userIsOwner = true
|
||||
component.userCanEdit = true
|
||||
component.versions = [
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
{ id: 10, is_root: false, checksum: 'bbbb' },
|
||||
]
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
|
||||
PermissionAction.Delete,
|
||||
PermissionType.Document
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('beginEditingVersion should set active row and draft label', () => {
|
||||
component.userCanEdit = true
|
||||
component.userIsOwner = true
|
||||
|
||||
+7
-1
@@ -25,6 +25,7 @@ import {
|
||||
tap,
|
||||
} from 'rxjs/operators'
|
||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
WebsocketStatusService,
|
||||
} from 'src/app/services/websocket-status.service'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
|
||||
|
||||
@Component({
|
||||
@@ -44,11 +46,15 @@ import { AddExistingDocumentVersionDialogComponent } from './add-existing-docume
|
||||
NgbDropdownModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ConfirmButtonComponent,
|
||||
IfPermissionsDirective,
|
||||
SlicePipe,
|
||||
CustomDatePipe,
|
||||
],
|
||||
})
|
||||
export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
export class DocumentVersionDropdownComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnChanges, OnDestroy
|
||||
{
|
||||
UploadState = UploadState
|
||||
|
||||
@Input() documentId: number
|
||||
|
||||
@@ -1360,6 +1360,145 @@ class TestBulkEditObjectPermissions(APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(response.content, b"Insufficient permissions")
|
||||
|
||||
def test_bulk_edit_object_permissions_shared_object_not_owner(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Object owned by another user, shared with the logged in user with
|
||||
change permissions
|
||||
WHEN:
|
||||
- bulk_edit_objects API endpoint is called with set_permissions operation
|
||||
THEN:
|
||||
- User is not able to take ownership or change permissions, consistent
|
||||
with the single object API
|
||||
"""
|
||||
self.t1.owner = self.user2
|
||||
self.t1.save()
|
||||
assign_perm("view_tag", self.user1, self.t1)
|
||||
assign_perm("change_tag", self.user1, self.t1)
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_tag", "change_tag"],
|
||||
),
|
||||
)
|
||||
user1 = User.objects.get(pk=self.user1.pk)
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
json.dumps(
|
||||
{
|
||||
"objects": [self.t1.id],
|
||||
"object_type": "tags",
|
||||
"operation": "set_permissions",
|
||||
"owner": user1.id,
|
||||
"permissions": {
|
||||
"view": {"users": [user1.id], "groups": []},
|
||||
"change": {"users": [user1.id], "groups": []},
|
||||
},
|
||||
"merge": False,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(Tag.objects.get(pk=self.t1.id).owner, self.user2)
|
||||
|
||||
# the single object endpoint refuses the same request
|
||||
response = self.client.patch(
|
||||
f"/api/tags/{self.t1.id}/",
|
||||
{"owner": user1.id},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(Tag.objects.get(pk=self.t1.id).owner, self.user2)
|
||||
|
||||
def test_bulk_edit_object_permissions_all_with_shared_objects(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Objects owned by the logged in user, unowned objects and objects owned
|
||||
by another user but shared with the logged in user
|
||||
WHEN:
|
||||
- bulk_edit_objects API endpoint is called with set_permissions operation
|
||||
and all = True
|
||||
THEN:
|
||||
- The request is refused and no objects are changed
|
||||
"""
|
||||
owned = Tag.objects.create(name="owned", owner=self.user1)
|
||||
shared = Tag.objects.create(name="shared", owner=self.user2)
|
||||
assign_perm("view_tag", self.user1, shared)
|
||||
assign_perm("change_tag", self.user1, shared)
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_tag", "change_tag"],
|
||||
),
|
||||
)
|
||||
user1 = User.objects.get(pk=self.user1.pk)
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
json.dumps(
|
||||
{
|
||||
"objects": [],
|
||||
"all": True,
|
||||
"object_type": "tags",
|
||||
"operation": "set_permissions",
|
||||
"permissions": {
|
||||
"view": {"users": [self.user3.id], "groups": []},
|
||||
"change": {"users": [self.user3.id], "groups": []},
|
||||
},
|
||||
"merge": False,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
# nothing was changed, including the objects the user does own
|
||||
self.assertNotIn(self.user3, get_users_with_perms(owned))
|
||||
self.assertNotIn(self.user3, get_users_with_perms(self.t1))
|
||||
self.assertNotIn(self.user3, get_users_with_perms(shared))
|
||||
self.assertEqual(Tag.objects.get(pk=shared.pk).owner, self.user2)
|
||||
|
||||
def test_bulk_edit_object_delete_shared_object_not_owner(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Object owned by another user, shared with the logged in user with
|
||||
change and delete permissions
|
||||
WHEN:
|
||||
- bulk_edit_objects API endpoint is called with delete operation
|
||||
THEN:
|
||||
- User is not able to delete the object, consistent with documents
|
||||
"""
|
||||
self.t1.owner = self.user2
|
||||
self.t1.save()
|
||||
assign_perm("view_tag", self.user1, self.t1)
|
||||
assign_perm("change_tag", self.user1, self.t1)
|
||||
assign_perm("delete_tag", self.user1, self.t1)
|
||||
self.user1.user_permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_tag", "change_tag", "delete_tag"],
|
||||
),
|
||||
)
|
||||
user1 = User.objects.get(pk=self.user1.pk)
|
||||
self.client.force_authenticate(user=user1)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
json.dumps(
|
||||
{
|
||||
"objects": [self.t1.id],
|
||||
"object_type": "tags",
|
||||
"operation": "delete",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertTrue(Tag.objects.filter(pk=self.t1.id).exists())
|
||||
|
||||
def test_bulk_edit_object_permissions_validation(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -677,16 +677,54 @@ class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
||||
def test_apply_to_all_tags_excludes_unpermitted_tag(self, rest_api_client):
|
||||
owner = User.objects.create_user(username="tags_owner")
|
||||
requester = User.objects.create_user(username="tags_requester")
|
||||
new_owner = User.objects.create_user(username="tags_new_owner")
|
||||
# grant the global change_tag permission so the object-level
|
||||
# filtering (not the global has_perm check) is what's under test
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
visible = TagFactory(owner=owner)
|
||||
visible = TagFactory(owner=requester)
|
||||
hidden = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", requester, visible)
|
||||
assign_perm("change_tag", requester, visible)
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
{
|
||||
"object_type": "tags",
|
||||
"operation": "set_permissions",
|
||||
"all": True,
|
||||
"filters": {},
|
||||
"owner": new_owner.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
# The apply_to_all dispatch must resolve permitted objects up front:
|
||||
# the requester's own tag gets its owner reassigned, while the tag
|
||||
# owned by someone else is excluded entirely and keeps its owner.
|
||||
visible.refresh_from_db()
|
||||
hidden.refresh_from_db()
|
||||
assert visible.owner == new_owner
|
||||
assert hidden.owner == owner
|
||||
|
||||
def test_apply_to_all_tags_refuses_shared_but_unowned_tag(self, rest_api_client):
|
||||
"""
|
||||
A tag owned by someone else but shared with the requester is inside the
|
||||
permitted set, so it reaches the ownership gate and fails the whole
|
||||
request rather than being silently skipped. Editing permissions is
|
||||
limited to the owner, same as documents.
|
||||
"""
|
||||
owner = User.objects.create_user(username="shared_tags_owner")
|
||||
requester = User.objects.create_user(username="shared_tags_requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="change_tag"),
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
owned = TagFactory(owner=requester)
|
||||
shared = TagFactory(owner=owner)
|
||||
assign_perm("view_tag", requester, shared)
|
||||
assign_perm("change_tag", requester, shared)
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
@@ -699,16 +737,12 @@ class TestBulkEditObjectsApplyToAllPermissionBoundary:
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
|
||||
# The apply_to_all dispatch must resolve permitted objects up front:
|
||||
# the visible tag (object-level change_tag granted) gets its owner
|
||||
# reassigned, while the hidden tag (no object-level grant) is
|
||||
# excluded entirely and keeps its original owner.
|
||||
visible.refresh_from_db()
|
||||
hidden.refresh_from_db()
|
||||
assert visible.owner == requester
|
||||
assert hidden.owner == owner
|
||||
owned.refresh_from_db()
|
||||
shared.refresh_from_db()
|
||||
assert shared.owner == owner
|
||||
assert owned.owner == requester
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -720,8 +754,8 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
"""
|
||||
GIVEN:
|
||||
- A tag hierarchy (parent -> permitted_child, unpermitted_child)
|
||||
- A non-superuser requester with object-level change_tag granted
|
||||
on the parent and on only ONE of the two children
|
||||
- A non-superuser requester who owns the parent and only ONE of
|
||||
the two children
|
||||
WHEN:
|
||||
- bulk_edit_objects is called with all=True and a filter that
|
||||
matches only the root (parent) tag, engaging the
|
||||
@@ -743,6 +777,7 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
"""
|
||||
owner = User.objects.create_user(username="tag_hierarchy_owner")
|
||||
requester = User.objects.create_user(username="tag_hierarchy_requester")
|
||||
new_owner = User.objects.create_user(username="tag_hierarchy_new_owner")
|
||||
# global change_tag permission so the has_perm() gate passes and the
|
||||
# object-level permitted_object_ids filtering is what's under test
|
||||
requester.user_permissions.add(
|
||||
@@ -750,9 +785,9 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
)
|
||||
rest_api_client.force_authenticate(user=requester)
|
||||
|
||||
parent = TagFactory(owner=owner, name="parent-tag")
|
||||
parent = TagFactory(owner=requester, name="parent-tag")
|
||||
permitted_child = TagFactory(
|
||||
owner=owner,
|
||||
owner=requester,
|
||||
name="permitted-child-tag",
|
||||
tn_parent=parent,
|
||||
)
|
||||
@@ -761,9 +796,6 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
name="unpermitted-child-tag",
|
||||
tn_parent=parent,
|
||||
)
|
||||
assign_perm("change_tag", requester, parent)
|
||||
assign_perm("change_tag", requester, permitted_child)
|
||||
# unpermitted_child is intentionally NOT granted change_tag
|
||||
|
||||
response = rest_api_client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
@@ -772,7 +804,7 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
"operation": "set_permissions",
|
||||
"all": True,
|
||||
"filters": {"is_root": True},
|
||||
"owner": requester.pk,
|
||||
"owner": new_owner.pk,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -781,8 +813,8 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
||||
parent.refresh_from_db()
|
||||
permitted_child.refresh_from_db()
|
||||
unpermitted_child.refresh_from_db()
|
||||
assert parent.owner == requester
|
||||
assert permitted_child.owner == requester
|
||||
assert parent.owner == new_owner
|
||||
assert permitted_child.owner == new_owner
|
||||
assert unpermitted_child.owner == owner
|
||||
|
||||
|
||||
|
||||
@@ -4879,10 +4879,11 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
|
||||
if not user.is_superuser:
|
||||
perm = f"documents.{perm_codename}"
|
||||
# Limited to the owner (or unowned), same as documents, see BulkEditView
|
||||
has_perms = (
|
||||
user.has_perm(perm)
|
||||
and not objs.exclude(
|
||||
pk__in=permitted_object_ids(user, object_class, perm_codename),
|
||||
Q(owner=user) | Q(owner__isnull=True),
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-19 14:23+0000\n"
|
||||
"POT-Creation-Date: 2026-08-19 18:18+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -2170,151 +2170,151 @@ msgstr ""
|
||||
msgid "paperless application settings"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:556
|
||||
#: paperless/settings/__init__.py:562
|
||||
msgid "English (US)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:557
|
||||
#: paperless/settings/__init__.py:563
|
||||
msgid "Arabic"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:558
|
||||
#: paperless/settings/__init__.py:564
|
||||
msgid "Afrikaans"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:559
|
||||
#: paperless/settings/__init__.py:565
|
||||
msgid "Belarusian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:560
|
||||
#: paperless/settings/__init__.py:566
|
||||
msgid "Bulgarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:561
|
||||
#: paperless/settings/__init__.py:567
|
||||
msgid "Catalan"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:562
|
||||
#: paperless/settings/__init__.py:568
|
||||
msgid "Czech"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:563
|
||||
#: paperless/settings/__init__.py:569
|
||||
msgid "Danish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:564
|
||||
#: paperless/settings/__init__.py:570
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:565
|
||||
#: paperless/settings/__init__.py:571
|
||||
msgid "Greek"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:566
|
||||
#: paperless/settings/__init__.py:572
|
||||
msgid "English (GB)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:567
|
||||
#: paperless/settings/__init__.py:573
|
||||
msgid "Spanish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:568
|
||||
#: paperless/settings/__init__.py:574
|
||||
msgid "Persian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:569
|
||||
#: paperless/settings/__init__.py:575
|
||||
msgid "Finnish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:570
|
||||
#: paperless/settings/__init__.py:576
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:571
|
||||
#: paperless/settings/__init__.py:577
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:572
|
||||
#: paperless/settings/__init__.py:578
|
||||
msgid "Indonesian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:573
|
||||
#: paperless/settings/__init__.py:579
|
||||
msgid "Italian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:574
|
||||
#: paperless/settings/__init__.py:580
|
||||
msgid "Japanese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:575
|
||||
#: paperless/settings/__init__.py:581
|
||||
msgid "Korean"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:576
|
||||
#: paperless/settings/__init__.py:582
|
||||
msgid "Luxembourgish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:577
|
||||
#: paperless/settings/__init__.py:583
|
||||
msgid "Norwegian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:578
|
||||
#: paperless/settings/__init__.py:584
|
||||
msgid "Dutch"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:579
|
||||
#: paperless/settings/__init__.py:585
|
||||
msgid "Polish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:580
|
||||
#: paperless/settings/__init__.py:586
|
||||
msgid "Portuguese (Brazil)"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:581
|
||||
#: paperless/settings/__init__.py:587
|
||||
msgid "Portuguese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:582
|
||||
#: paperless/settings/__init__.py:588
|
||||
msgid "Romanian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:583
|
||||
#: paperless/settings/__init__.py:589
|
||||
msgid "Russian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:584
|
||||
#: paperless/settings/__init__.py:590
|
||||
msgid "Slovak"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:585
|
||||
#: paperless/settings/__init__.py:591
|
||||
msgid "Slovenian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:586
|
||||
#: paperless/settings/__init__.py:592
|
||||
msgid "Serbian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:587
|
||||
#: paperless/settings/__init__.py:593
|
||||
msgid "Swedish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:588
|
||||
#: paperless/settings/__init__.py:594
|
||||
msgid "Turkish"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:589
|
||||
#: paperless/settings/__init__.py:595
|
||||
msgid "Ukrainian"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:590
|
||||
#: paperless/settings/__init__.py:596
|
||||
msgid "Vietnamese"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:591
|
||||
#: paperless/settings/__init__.py:597
|
||||
msgid "Chinese Simplified"
|
||||
msgstr ""
|
||||
|
||||
#: paperless/settings/__init__.py:592
|
||||
#: paperless/settings/__init__.py:598
|
||||
msgid "Chinese Traditional"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -172,6 +173,15 @@ def parse_beat_schedule() -> dict:
|
||||
# Don't add disabled tasks to the schedule
|
||||
if value == "disable":
|
||||
continue
|
||||
if (
|
||||
task["env_key"] == "PAPERLESS_EMAIL_TASK_CRON"
|
||||
and task["env_key"] not in os.environ
|
||||
):
|
||||
# Spread default polling across the ten-minute interval.
|
||||
secret = os.environ["PAPERLESS_SECRET_KEY"].encode()
|
||||
offset = int.from_bytes(sha256(secret).digest()) % 10
|
||||
minutes = ",".join(str(minute) for minute in range(offset, 60, 10))
|
||||
value = f"{minutes} * * * *"
|
||||
# I find https://crontab.guru/ super helpful
|
||||
# crontab(5) format
|
||||
# - five time-and-date fields
|
||||
|
||||
@@ -168,6 +168,7 @@ class TestParseHostingSettings:
|
||||
def make_expected_schedule(
|
||||
overrides: dict[str, dict[str, Any]] | None = None,
|
||||
disabled: set[str] | None = None,
|
||||
email_minute: str = "6,16,26,36,46,56",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the expected schedule with optional overrides and disabled tasks.
|
||||
@@ -185,7 +186,7 @@ def make_expected_schedule(
|
||||
schedule: dict[str, Any] = {
|
||||
"Check all e-mail accounts": {
|
||||
"task": "paperless_mail.tasks.process_mail_accounts",
|
||||
"schedule": crontab(minute="*/10"),
|
||||
"schedule": crontab(minute=email_minute),
|
||||
"options": {
|
||||
"expires": mail_expire,
|
||||
"headers": {"trigger_source": "scheduled"},
|
||||
@@ -266,6 +267,11 @@ class TestParseBeatSchedule:
|
||||
("env", "expected"),
|
||||
[
|
||||
pytest.param({}, make_expected_schedule(), id="defaults"),
|
||||
pytest.param(
|
||||
{"PAPERLESS_EMAIL_TASK_CRON": "*/10 * * * *"},
|
||||
make_expected_schedule(email_minute="*/10"),
|
||||
id="email-explicit-default",
|
||||
),
|
||||
pytest.param(
|
||||
{"PAPERLESS_EMAIL_TASK_CRON": "*/50 * * * mon"},
|
||||
make_expected_schedule(
|
||||
@@ -304,7 +310,11 @@ class TestParseBeatSchedule:
|
||||
expected: dict[str, Any],
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch.dict(os.environ, env, clear=False)
|
||||
mocker.patch.dict(
|
||||
os.environ,
|
||||
{"PAPERLESS_SECRET_KEY": "test-secret", **env},
|
||||
clear=False,
|
||||
)
|
||||
schedule = parse_beat_schedule()
|
||||
assert schedule == expected
|
||||
|
||||
|
||||
Reference in New Issue
Block a user