mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-16 22:15:58 +00:00
Compare commits
7 Commits
feature-re
...
v2.20.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f26c01c6f | ||
|
|
92e133eeb0 | ||
|
|
06b2d5102c | ||
|
|
40255cfdbb | ||
|
|
d919c341b1 | ||
|
|
ba0a80a8ad | ||
|
|
60319c6d37 |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.10"
|
version = "2.20.11"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "2.20.10",
|
"version": "2.20.11",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -631,6 +631,59 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('deselecting a parent clears selected descendants', () => {
|
||||||
|
const root: Tag = { id: 100, name: 'Root Tag' }
|
||||||
|
const child: Tag = { id: 101, name: 'Child Tag', parent: root.id }
|
||||||
|
const grandchild: Tag = {
|
||||||
|
id: 102,
|
||||||
|
name: 'Grandchild Tag',
|
||||||
|
parent: child.id,
|
||||||
|
}
|
||||||
|
const other: Tag = { id: 103, name: 'Other Tag' }
|
||||||
|
|
||||||
|
selectionModel.items = [root, child, grandchild, other]
|
||||||
|
selectionModel.set(root.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(child.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(grandchild.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(other.id, ToggleableItemState.Selected, false)
|
||||||
|
|
||||||
|
selectionModel.toggle(root.id, false)
|
||||||
|
|
||||||
|
expect(selectionModel.getSelectedItems()).toEqual([other])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un-excluding a parent clears excluded descendants', () => {
|
||||||
|
const root: Tag = { id: 110, name: 'Root Tag' }
|
||||||
|
const child: Tag = { id: 111, name: 'Child Tag', parent: root.id }
|
||||||
|
const other: Tag = { id: 112, name: 'Other Tag' }
|
||||||
|
|
||||||
|
selectionModel.items = [root, child, other]
|
||||||
|
selectionModel.set(root.id, ToggleableItemState.Excluded, false)
|
||||||
|
selectionModel.set(child.id, ToggleableItemState.Excluded, false)
|
||||||
|
selectionModel.set(other.id, ToggleableItemState.Excluded, false)
|
||||||
|
|
||||||
|
selectionModel.exclude(root.id, false)
|
||||||
|
|
||||||
|
expect(selectionModel.getExcludedItems()).toEqual([other])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excluding a selected parent clears selected descendants', () => {
|
||||||
|
const root: Tag = { id: 120, name: 'Root Tag' }
|
||||||
|
const child: Tag = { id: 121, name: 'Child Tag', parent: root.id }
|
||||||
|
const other: Tag = { id: 122, name: 'Other Tag' }
|
||||||
|
|
||||||
|
selectionModel.manyToOne = true
|
||||||
|
selectionModel.items = [root, child, other]
|
||||||
|
selectionModel.set(root.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(child.id, ToggleableItemState.Selected, false)
|
||||||
|
selectionModel.set(other.id, ToggleableItemState.Selected, false)
|
||||||
|
|
||||||
|
selectionModel.exclude(root.id, false)
|
||||||
|
|
||||||
|
expect(selectionModel.getExcludedItems()).toEqual([root])
|
||||||
|
expect(selectionModel.getSelectedItems()).toEqual([other])
|
||||||
|
})
|
||||||
|
|
||||||
it('resorts items immediately when document count sorting enabled', () => {
|
it('resorts items immediately when document count sorting enabled', () => {
|
||||||
const apple: Tag = { id: 55, name: 'Apple' }
|
const apple: Tag = { id: 55, name: 'Apple' }
|
||||||
const zebra: Tag = { id: 56, name: 'Zebra' }
|
const zebra: Tag = { id: 56, name: 'Zebra' }
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
state == ToggleableItemState.Excluded
|
state == ToggleableItemState.Excluded
|
||||||
) {
|
) {
|
||||||
this.temporarySelectionStates.delete(id)
|
this.temporarySelectionStates.delete(id)
|
||||||
|
this.clearDescendantSelections(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -257,6 +258,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
||||||
|
this.clearDescendantSelections(id)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of this.temporarySelectionStates.keys()) {
|
||||||
@@ -277,9 +279,15 @@ export class FilterableDropdownSelectionModel {
|
|||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
this.temporarySelectionStates.set(id, newState)
|
this.temporarySelectionStates.set(id, newState)
|
||||||
|
if (newState == ToggleableItemState.Excluded) {
|
||||||
|
this.clearDescendantSelections(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||||
this.temporarySelectionStates.delete(id)
|
this.temporarySelectionStates.delete(id)
|
||||||
|
if (id) {
|
||||||
|
this.clearDescendantSelections(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
@@ -291,6 +299,33 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearDescendantSelections(id: number) {
|
||||||
|
for (const descendantID of this.getDescendantIDs(id)) {
|
||||||
|
this.temporarySelectionStates.delete(descendantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDescendantIDs(id: number): number[] {
|
||||||
|
const descendants: number[] = []
|
||||||
|
const queue: number[] = [id]
|
||||||
|
|
||||||
|
while (queue.length) {
|
||||||
|
const parentID = queue.shift()
|
||||||
|
for (const item of this._items) {
|
||||||
|
if (
|
||||||
|
typeof item?.id === 'number' &&
|
||||||
|
typeof (item as any)['parent'] === 'number' &&
|
||||||
|
(item as any)['parent'] === parentID
|
||||||
|
) {
|
||||||
|
descendants.push(item.id)
|
||||||
|
queue.push(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return descendants
|
||||||
|
}
|
||||||
|
|
||||||
get logicalOperator(): LogicalOperator {
|
get logicalOperator(): LogicalOperator {
|
||||||
return this.temporaryLogicalOperator
|
return this.temporaryLogicalOperator
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (document && displayFields?.includes(DisplayField.TAGS)) {
|
@if (document && displayFields?.includes(DisplayField.TAGS)) {
|
||||||
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
|
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6" [class.tags-no-wrap]="document.tags.length > 3">
|
||||||
@for (tagID of tagIDs; track tagID) {
|
@for (tagID of tagIDs; track tagID) {
|
||||||
<pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
|
<pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,14 @@ a {
|
|||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
row-gap: .2rem;
|
row-gap: .2rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
||||||
|
&.tags-no-wrap {
|
||||||
|
::ng-deep .badge {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ describe('DocumentCardSmallComponent', () => {
|
|||||||
).toHaveLength(6)
|
).toHaveLength(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should clear hidden tag counter when tag count falls below the limit', () => {
|
||||||
|
expect(component.moreTags).toEqual(3)
|
||||||
|
|
||||||
|
component.document.tags = [1, 2, 3, 4, 5, 6]
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.moreTags).toBeNull()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('+ 3')
|
||||||
|
})
|
||||||
|
|
||||||
it('should try to close the preview on mouse leave', () => {
|
it('should try to close the preview on mouse leave', () => {
|
||||||
component.popupPreview = {
|
component.popupPreview = {
|
||||||
close: jest.fn(),
|
close: jest.fn(),
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export class DocumentCardSmallComponent
|
|||||||
this.moreTags = this.document.tags.length - (limit - 1)
|
this.moreTags = this.document.tags.length - (limit - 1)
|
||||||
return this.document.tags.slice(0, limit - 1)
|
return this.document.tags.slice(0, limit - 1)
|
||||||
} else {
|
} else {
|
||||||
|
this.moreTags = null
|
||||||
return this.document.tags
|
return this.document.tags
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const environment = {
|
|||||||
apiVersion: '9', // match src/paperless/settings.py
|
apiVersion: '9', // match src/paperless/settings.py
|
||||||
appTitle: 'Paperless-ngx',
|
appTitle: 'Paperless-ngx',
|
||||||
tag: 'prod',
|
tag: 'prod',
|
||||||
version: '2.20.10',
|
version: '2.20.11',
|
||||||
webSocketHost: window.location.host,
|
webSocketHost: window.location.host,
|
||||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
background-color: var(--pngx-body-color-accent);
|
background-color: var(--pngx-body-color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-group-item-action:not(.active):active {
|
||||||
|
--bs-list-group-action-active-color: var(--bs-body-color);
|
||||||
|
--bs-list-group-action-active-bg: var(--pngx-bg-darker);
|
||||||
|
}
|
||||||
.search-container {
|
.search-container {
|
||||||
input, input:focus, i-bs[name="search"] , ::placeholder {
|
input, input:focus, i-bs[name="search"] , ::placeholder {
|
||||||
color: var(--pngx-primary-text-contrast) !important;
|
color: var(--pngx-primary-text-contrast) !important;
|
||||||
|
|||||||
@@ -833,6 +833,8 @@ def run_workflows(
|
|||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
document.title = document.title[:128]
|
document.title = document.title[:128]
|
||||||
|
# Make sure the filename and archive filename are accurate
|
||||||
|
document.refresh_from_db(fields=["filename", "archive_filename"])
|
||||||
# save first before setting tags
|
# save first before setting tags
|
||||||
document.save()
|
document.save()
|
||||||
document.tags.set(doc_tag_ids)
|
document.tags.set(doc_tag_ids)
|
||||||
|
|||||||
@@ -888,6 +888,19 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"username": "user4",
|
||||||
|
"is_superuser": "true",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
self.client.force_authenticate(user2)
|
self.client.force_authenticate(user2)
|
||||||
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
@@ -920,6 +933,65 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
|||||||
returned_user1 = User.objects.get(pk=user1.pk)
|
returned_user1 = User.objects.get(pk=user1.pk)
|
||||||
self.assertEqual(returned_user1.is_superuser, False)
|
self.assertEqual(returned_user1.is_superuser, False)
|
||||||
|
|
||||||
|
def test_only_superusers_can_create_or_alter_staff_status(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing user account
|
||||||
|
WHEN:
|
||||||
|
- API request is made to add a user account with staff status
|
||||||
|
- API request is made to change staff status
|
||||||
|
THEN:
|
||||||
|
- Only superusers can change staff status
|
||||||
|
"""
|
||||||
|
|
||||||
|
user1 = User.objects.create_user(username="user1")
|
||||||
|
user1.user_permissions.add(*Permission.objects.all())
|
||||||
|
user2 = User.objects.create_superuser(username="user2")
|
||||||
|
|
||||||
|
self.client.force_authenticate(user1)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{user1.pk}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"is_staff": "true",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"username": "user3",
|
||||||
|
"is_staff": 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user2)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{user1.pk}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"is_staff": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
returned_user1 = User.objects.get(pk=user1.pk)
|
||||||
|
self.assertEqual(returned_user1.is_staff, True)
|
||||||
|
|
||||||
|
|
||||||
class TestApiGroup(DirectoriesMixin, APITestCase):
|
class TestApiGroup(DirectoriesMixin, APITestCase):
|
||||||
ENDPOINT = "/api/groups/"
|
ENDPOINT = "/api/groups/"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from rest_framework.test import APIClient
|
|||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.file_handling import create_source_path_directory
|
from documents.file_handling import create_source_path_directory
|
||||||
|
from documents.file_handling import generate_filename
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.workflows.webhooks import send_webhook
|
from documents.workflows.webhooks import send_webhook
|
||||||
@@ -898,6 +899,63 @@ class TestWorkflows(
|
|||||||
expected_str = f"Document matched {trigger} from {w}"
|
expected_str = f"Document matched {trigger} from {w}"
|
||||||
self.assertIn(expected_str, cm.output[0])
|
self.assertIn(expected_str, cm.output[0])
|
||||||
|
|
||||||
|
def test_workflow_assign_custom_field_keeps_storage_filename_in_sync(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing document with a storage path template that depends on a custom field
|
||||||
|
- Existing workflow triggered on document update assigning that custom field
|
||||||
|
WHEN:
|
||||||
|
- Workflow runs for the document
|
||||||
|
THEN:
|
||||||
|
- The database filename remains aligned with the moved file on disk
|
||||||
|
"""
|
||||||
|
storage_path = StoragePath.objects.create(
|
||||||
|
name="workflow-custom-field-path",
|
||||||
|
path="{{ custom_fields|get_cf_value('Custom Field 1', 'none') }}/{{ title }}",
|
||||||
|
)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="workflow custom field sync",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
checksum="workflow-custom-field-sync",
|
||||||
|
storage_path=storage_path,
|
||||||
|
original_filename="workflow-custom-field-sync.pdf",
|
||||||
|
)
|
||||||
|
CustomFieldInstance.objects.create(
|
||||||
|
document=doc,
|
||||||
|
field=self.cf1,
|
||||||
|
value_text="initial",
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_unique_filename(doc)
|
||||||
|
destination = (settings.ORIGINALS_DIR / generated).resolve()
|
||||||
|
create_source_path_directory(destination)
|
||||||
|
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
|
||||||
|
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
|
||||||
|
doc.refresh_from_db()
|
||||||
|
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||||
|
assign_custom_fields_values={self.cf1.pk: "cars"},
|
||||||
|
)
|
||||||
|
action.assign_custom_fields.add(self.cf1.pk)
|
||||||
|
workflow = Workflow.objects.create(
|
||||||
|
name="Workflow custom field filename sync",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(action)
|
||||||
|
workflow.save()
|
||||||
|
|
||||||
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||||
|
|
||||||
|
doc.refresh_from_db()
|
||||||
|
expected_filename = generate_filename(doc)
|
||||||
|
self.assertEqual(Path(doc.filename), expected_filename)
|
||||||
|
self.assertTrue(doc.source_path.is_file())
|
||||||
|
|
||||||
def test_document_added_workflow(self):
|
def test_document_added_workflow(self):
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
__version__: Final[tuple[int, int, int]] = (2, 20, 10)
|
__version__: Final[tuple[int, int, int]] = (2, 20, 11)
|
||||||
# Version string like X.Y.Z
|
# Version string like X.Y.Z
|
||||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||||
# Version string like X.Y
|
# Version string like X.Y
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from drf_spectacular.utils import extend_schema_view
|
|||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from rest_framework.authtoken.views import ObtainAuthToken
|
from rest_framework.authtoken.views import ObtainAuthToken
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from rest_framework.fields import BooleanField
|
||||||
from rest_framework.filters import OrderingFilter
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
@@ -103,6 +105,7 @@ class FaviconView(View):
|
|||||||
|
|
||||||
|
|
||||||
class UserViewSet(ModelViewSet):
|
class UserViewSet(ModelViewSet):
|
||||||
|
_BOOL_NOT_PROVIDED = object()
|
||||||
model = User
|
model = User
|
||||||
|
|
||||||
queryset = User.objects.exclude(
|
queryset = User.objects.exclude(
|
||||||
@@ -116,27 +119,65 @@ class UserViewSet(ModelViewSet):
|
|||||||
filterset_class = UserFilterSet
|
filterset_class = UserFilterSet
|
||||||
ordering_fields = ("username",)
|
ordering_fields = ("username",)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_requested_bool(data, key: str):
|
||||||
|
if key not in data:
|
||||||
|
return UserViewSet._BOOL_NOT_PROVIDED
|
||||||
|
try:
|
||||||
|
return BooleanField().to_internal_value(data.get(key))
|
||||||
|
except ValidationError:
|
||||||
|
# Let serializer validation report invalid values as 400 responses
|
||||||
|
return UserViewSet._BOOL_NOT_PROVIDED
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
if not request.user.is_superuser and request.data.get("is_superuser") is True:
|
requested_is_superuser = self._parse_requested_bool(
|
||||||
return HttpResponseForbidden(
|
request.data,
|
||||||
"Superuser status can only be granted by a superuser",
|
"is_superuser",
|
||||||
)
|
)
|
||||||
|
requested_is_staff = self._parse_requested_bool(request.data, "is_staff")
|
||||||
|
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
if requested_is_superuser is True:
|
||||||
|
return HttpResponseForbidden(
|
||||||
|
"Superuser status can only be granted by a superuser",
|
||||||
|
)
|
||||||
|
if requested_is_staff is True:
|
||||||
|
return HttpResponseForbidden(
|
||||||
|
"Staff status can only be granted by a superuser",
|
||||||
|
)
|
||||||
|
|
||||||
return super().create(request, *args, **kwargs)
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
user_to_update: User = self.get_object()
|
user_to_update: User = self.get_object()
|
||||||
|
|
||||||
if not request.user.is_superuser and user_to_update.is_superuser:
|
if not request.user.is_superuser and user_to_update.is_superuser:
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Superusers can only be modified by other superusers",
|
"Superusers can only be modified by other superusers",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
requested_is_superuser = self._parse_requested_bool(
|
||||||
|
request.data,
|
||||||
|
"is_superuser",
|
||||||
|
)
|
||||||
|
requested_is_staff = self._parse_requested_bool(request.data, "is_staff")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not request.user.is_superuser
|
not request.user.is_superuser
|
||||||
and request.data.get("is_superuser") is not None
|
and requested_is_superuser is not self._BOOL_NOT_PROVIDED
|
||||||
and request.data.get("is_superuser") != user_to_update.is_superuser
|
and requested_is_superuser != user_to_update.is_superuser
|
||||||
):
|
):
|
||||||
return HttpResponseForbidden(
|
return HttpResponseForbidden(
|
||||||
"Superuser status can only be changed by a superuser",
|
"Superuser status can only be changed by a superuser",
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
not request.user.is_superuser
|
||||||
|
and requested_is_staff is not self._BOOL_NOT_PROVIDED
|
||||||
|
and requested_is_staff != user_to_update.is_staff
|
||||||
|
):
|
||||||
|
return HttpResponseForbidden(
|
||||||
|
"Staff status can only be changed by a superuser",
|
||||||
|
)
|
||||||
return super().update(request, *args, **kwargs)
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -1991,7 +1991,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "2.20.10"
|
version = "2.20.11"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
|
|||||||
Reference in New Issue
Block a user