Enhancement: support owner permissions for file tasks (#8195)

This commit is contained in:
shamoon 2024-11-20 12:25:53 -08:00 committed by GitHub
parent 9c1561adfb
commit 8bfe68743d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 112 additions and 27 deletions

View File

@ -556,3 +556,11 @@ Initial API version.
- Consumption templates were refactored to workflows and API endpoints - Consumption templates were refactored to workflows and API endpoints
changed as such. changed as such.
#### Version 5
- Added bulk deletion methods for documents and objects.
#### Version 6
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.

View File

@ -30,4 +30,6 @@ export interface PaperlessTask extends ObjectWithId {
result?: string result?: string
related_document?: number related_document?: number
owner?: number
} }

View File

@ -48,7 +48,7 @@ describe('TasksService', () => {
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => { it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
tasksService.dismissTasks(new Set([1, 2, 3])) tasksService.dismissTasks(new Set([1, 2, 3]))
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}acknowledge_tasks/` `${environment.apiBaseUrl}tasks/acknowledge/`
) )
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({

View File

@ -64,7 +64,7 @@ export class TasksService {
public dismissTasks(task_ids: Set<number>) { public dismissTasks(task_ids: Set<number>) {
this.http this.http
.post(`${this.baseUrl}acknowledge_tasks/`, { .post(`${this.baseUrl}tasks/acknowledge/`, {
tasks: [...task_ids], tasks: [...task_ids],
}) })
.pipe(first()) .pipe(first())

View File

@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
export const environment = { export const environment = {
production: true, production: true,
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '5', apiVersion: '6',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: '2.13.5', version: '2.13.5',
webSocketHost: window.location.host, webSocketHost: window.location.host,

View File

@ -5,7 +5,7 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'http://localhost:8000/api/', apiBaseUrl: 'http://localhost:8000/api/',
apiVersion: '5', apiVersion: '6',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: 'DEVELOPMENT', version: 'DEVELOPMENT',
webSocketHost: 'localhost:8000', webSocketHost: 'localhost:8000',

View File

@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2024-11-04 21:56
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1056_customfieldinstance_deleted_at_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="paperlesstask",
name="owner",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
]

View File

@ -641,7 +641,7 @@ class UiSettings(models.Model):
return self.user.username return self.user.username
class PaperlessTask(models.Model): class PaperlessTask(ModelWithOwner):
ALL_STATES = sorted(states.ALL_STATES) ALL_STATES = sorted(states.ALL_STATES)
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES)) TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))

View File

@ -1567,7 +1567,7 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
return ui_settings return ui_settings
class TasksViewSerializer(serializers.ModelSerializer): class TasksViewSerializer(OwnedObjectSerializer):
class Meta: class Meta:
model = PaperlessTask model = PaperlessTask
depth = 1 depth = 1
@ -1582,6 +1582,7 @@ class TasksViewSerializer(serializers.ModelSerializer):
"result", "result",
"acknowledged", "acknowledged",
"related_document", "related_document",
"owner",
) )
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()

View File

@ -939,9 +939,10 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
close_old_connections() close_old_connections()
task_args = body[0] task_args = body[0]
input_doc, _ = task_args input_doc, overrides = task_args
task_file_name = input_doc.original_file.name task_file_name = input_doc.original_file.name
user_id = overrides.owner_id if overrides else None
PaperlessTask.objects.create( PaperlessTask.objects.create(
task_id=headers["id"], task_id=headers["id"],
@ -952,6 +953,7 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
date_created=timezone.now(), date_created=timezone.now(),
date_started=None, date_started=None,
date_done=None, date_done=None,
owner_id=user_id,
) )
except Exception: # pragma: no cover except Exception: # pragma: no cover
# Don't let an exception in the signal handlers prevent # Don't let an exception in the signal handlers prevent

View File

@ -1,6 +1,7 @@
import uuid import uuid
import celery import celery
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@ -11,7 +12,6 @@ from documents.tests.utils import DirectoriesMixin
class TestTasks(DirectoriesMixin, APITestCase): class TestTasks(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/tasks/" ENDPOINT = "/api/tasks/"
ENDPOINT_ACKNOWLEDGE = "/api/acknowledge_tasks/"
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -125,7 +125,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
response = self.client.post( response = self.client.post(
self.ENDPOINT_ACKNOWLEDGE, self.ENDPOINT + "acknowledge/",
{"tasks": [task.id]}, {"tasks": [task.id]},
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -133,6 +133,52 @@ class TestTasks(DirectoriesMixin, APITestCase):
response = self.client.get(self.ENDPOINT) response = self.client.get(self.ENDPOINT)
self.assertEqual(len(response.data), 0) self.assertEqual(len(response.data), 0)
def test_tasks_owner_aware(self):
"""
GIVEN:
- Existing PaperlessTasks with owner and with no owner
WHEN:
- API call is made to get tasks
THEN:
- Only tasks with no owner or request user are returned
"""
regular_user = User.objects.create_user(username="test")
regular_user.user_permissions.add(*Permission.objects.all())
self.client.logout()
self.client.force_authenticate(user=regular_user)
task1 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_one.pdf",
owner=self.user,
)
task2 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_two.pdf",
)
task3 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_three.pdf",
owner=regular_user,
)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]["task_id"], task3.task_id)
self.assertEqual(response.data[1]["task_id"], task2.task_id)
acknowledge_response = self.client.post(
self.ENDPOINT + "acknowledge/",
{"tasks": [task1.id, task2.id, task3.id]},
)
self.assertEqual(acknowledge_response.status_code, status.HTTP_200_OK)
self.assertEqual(acknowledge_response.data, {"result": 2})
def test_task_result_no_error(self): def test_task_result_no_error(self):
""" """
GIVEN: GIVEN:

View File

@ -5,6 +5,7 @@ import celery
from django.test import TestCase from django.test import TestCase
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.signals.handlers import before_task_publish_handler from documents.signals.handlers import before_task_publish_handler
@ -48,7 +49,10 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
source=DocumentSource.ConsumeFolder, source=DocumentSource.ConsumeFolder,
original_file="/consume/hello-999.pdf", original_file="/consume/hello-999.pdf",
), ),
None, DocumentMetadataOverrides(
title="Hello world",
owner_id=1,
),
), ),
# kwargs # kwargs
{}, {},
@ -65,6 +69,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
self.assertEqual(headers["id"], task.task_id) self.assertEqual(headers["id"], task.task_id)
self.assertEqual("hello-999.pdf", task.task_file_name) self.assertEqual("hello-999.pdf", task.task_file_name)
self.assertEqual("documents.tasks.consume_file", task.task_name) self.assertEqual("documents.tasks.consume_file", task.task_name)
self.assertEqual(1, task.owner_id)
self.assertEqual(celery.states.PENDING, task.status) self.assertEqual(celery.states.PENDING, task.status)
def test_task_prerun_handler(self): def test_task_prerun_handler(self):

View File

@ -1705,6 +1705,7 @@ class RemoteVersionView(GenericAPIView):
class TasksViewSet(ReadOnlyModelViewSet): class TasksViewSet(ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = TasksViewSerializer serializer_class = TasksViewSerializer
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
def get_queryset(self): def get_queryset(self):
queryset = ( queryset = (
@ -1719,19 +1720,17 @@ class TasksViewSet(ReadOnlyModelViewSet):
queryset = PaperlessTask.objects.filter(task_id=task_id) queryset = PaperlessTask.objects.filter(task_id=task_id)
return queryset return queryset
@action(methods=["post"], detail=False)
class AcknowledgeTasksView(GenericAPIView): def acknowledge(self, request):
permission_classes = (IsAuthenticated,) serializer = AcknowledgeTasksViewSerializer(data=request.data)
serializer_class = AcknowledgeTasksViewSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
task_ids = serializer.validated_data.get("tasks")
tasks = serializer.validated_data.get("tasks")
try: try:
result = PaperlessTask.objects.filter(id__in=tasks).update( tasks = PaperlessTask.objects.filter(id__in=task_ids)
if request.user is not None and not request.user.is_superuser:
tasks = tasks.filter(owner=request.user) | tasks.filter(owner=None)
result = tasks.update(
acknowledged=True, acknowledged=True,
) )
return Response({"result": result}) return Response({"result": result})

View File

@ -333,7 +333,7 @@ REST_FRAMEWORK = {
"DEFAULT_VERSION": "1", "DEFAULT_VERSION": "1",
# Make sure these are ordered and that the most recent version appears # Make sure these are ordered and that the most recent version appears
# last # last
"ALLOWED_VERSIONS": ["1", "2", "3", "4", "5"], "ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6"],
} }
if DEBUG: if DEBUG:

View File

@ -18,7 +18,6 @@ from django.views.static import serve
from rest_framework.authtoken import views from rest_framework.authtoken import views
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from documents.views import AcknowledgeTasksView
from documents.views import BulkDownloadView from documents.views import BulkDownloadView
from documents.views import BulkEditObjectsView from documents.views import BulkEditObjectsView
from documents.views import BulkEditView from documents.views import BulkEditView
@ -132,11 +131,6 @@ urlpatterns = [
name="remoteversion", name="remoteversion",
), ),
re_path("^ui_settings/", UiSettingsView.as_view(), name="ui_settings"), re_path("^ui_settings/", UiSettingsView.as_view(), name="ui_settings"),
re_path(
"^acknowledge_tasks/",
AcknowledgeTasksView.as_view(),
name="acknowledge_tasks",
),
re_path( re_path(
"^mail_accounts/test/", "^mail_accounts/test/",
MailAccountTestView.as_view(), MailAccountTestView.as_view(),