Compare commits

...
13 changed files with 342 additions and 92 deletions
+1 -1
View File
@@ -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
View File
@@ -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
@@ -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>
@@ -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
@@ -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
+139
View File
@@ -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
+2 -1
View File
@@ -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()
)
+38 -38
View File
@@ -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 ""
+10
View File
@@ -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