diff --git a/docs/api.md b/docs/api.md index ccbde9b22..c5f20edd1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -556,3 +556,11 @@ Initial API version. - Consumption templates were refactored to workflows and API endpoints 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/`. diff --git a/src-ui/src/app/data/paperless-task.ts b/src-ui/src/app/data/paperless-task.ts index 08b30d44b..d15f006d7 100644 --- a/src-ui/src/app/data/paperless-task.ts +++ b/src-ui/src/app/data/paperless-task.ts @@ -30,4 +30,6 @@ export interface PaperlessTask extends ObjectWithId { result?: string related_document?: number + + owner?: number } diff --git a/src-ui/src/app/services/tasks.service.spec.ts b/src-ui/src/app/services/tasks.service.spec.ts index 41a374831..d746707b7 100644 --- a/src-ui/src/app/services/tasks.service.spec.ts +++ b/src-ui/src/app/services/tasks.service.spec.ts @@ -48,7 +48,7 @@ describe('TasksService', () => { it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => { tasksService.dismissTasks(new Set([1, 2, 3])) const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}acknowledge_tasks/` + `${environment.apiBaseUrl}tasks/acknowledge/` ) expect(req.request.method).toEqual('POST') expect(req.request.body).toEqual({ diff --git a/src-ui/src/app/services/tasks.service.ts b/src-ui/src/app/services/tasks.service.ts index e2c064e03..c3c8f1d2b 100644 --- a/src-ui/src/app/services/tasks.service.ts +++ b/src-ui/src/app/services/tasks.service.ts @@ -64,7 +64,7 @@ export class TasksService { public dismissTasks(task_ids: Set) { this.http - .post(`${this.baseUrl}acknowledge_tasks/`, { + .post(`${this.baseUrl}tasks/acknowledge/`, { tasks: [...task_ids], }) .pipe(first()) diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index 76ba37891..ba01ac9b0 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI) export const environment = { production: true, apiBaseUrl: document.baseURI + 'api/', - apiVersion: '5', + apiVersion: '6', appTitle: 'Paperless-ngx', version: '2.13.5', webSocketHost: window.location.host, diff --git a/src-ui/src/environments/environment.ts b/src-ui/src/environments/environment.ts index 18715e90f..6256f3ae3 100644 --- a/src-ui/src/environments/environment.ts +++ b/src-ui/src/environments/environment.ts @@ -5,7 +5,7 @@ export const environment = { production: false, apiBaseUrl: 'http://localhost:8000/api/', - apiVersion: '5', + apiVersion: '6', appTitle: 'Paperless-ngx', version: 'DEVELOPMENT', webSocketHost: 'localhost:8000', diff --git a/src/documents/migrations/1057_paperlesstask_owner.py b/src/documents/migrations/1057_paperlesstask_owner.py new file mode 100644 index 000000000..e9f108d3a --- /dev/null +++ b/src/documents/migrations/1057_paperlesstask_owner.py @@ -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", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 4528d5127..05226b0e9 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -641,7 +641,7 @@ class UiSettings(models.Model): return self.user.username -class PaperlessTask(models.Model): +class PaperlessTask(ModelWithOwner): ALL_STATES = sorted(states.ALL_STATES) TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES)) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 45bf672d8..f960cac24 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1567,7 +1567,7 @@ class UiSettingsViewSerializer(serializers.ModelSerializer): return ui_settings -class TasksViewSerializer(serializers.ModelSerializer): +class TasksViewSerializer(OwnedObjectSerializer): class Meta: model = PaperlessTask depth = 1 @@ -1582,6 +1582,7 @@ class TasksViewSerializer(serializers.ModelSerializer): "result", "acknowledged", "related_document", + "owner", ) type = serializers.SerializerMethodField() diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 114654c64..cd2e3972e 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -939,9 +939,10 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs): close_old_connections() task_args = body[0] - input_doc, _ = task_args + input_doc, overrides = task_args task_file_name = input_doc.original_file.name + user_id = overrides.owner_id if overrides else None PaperlessTask.objects.create( 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_started=None, date_done=None, + owner_id=user_id, ) except Exception: # pragma: no cover # Don't let an exception in the signal handlers prevent diff --git a/src/documents/tests/test_api_tasks.py b/src/documents/tests/test_api_tasks.py index 52ffb09fe..dd5425278 100644 --- a/src/documents/tests/test_api_tasks.py +++ b/src/documents/tests/test_api_tasks.py @@ -1,6 +1,7 @@ import uuid import celery +from django.contrib.auth.models import Permission from django.contrib.auth.models import User from rest_framework import status from rest_framework.test import APITestCase @@ -11,7 +12,6 @@ from documents.tests.utils import DirectoriesMixin class TestTasks(DirectoriesMixin, APITestCase): ENDPOINT = "/api/tasks/" - ENDPOINT_ACKNOWLEDGE = "/api/acknowledge_tasks/" def setUp(self): super().setUp() @@ -125,7 +125,7 @@ class TestTasks(DirectoriesMixin, APITestCase): self.assertEqual(len(response.data), 1) response = self.client.post( - self.ENDPOINT_ACKNOWLEDGE, + self.ENDPOINT + "acknowledge/", {"tasks": [task.id]}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -133,6 +133,52 @@ class TestTasks(DirectoriesMixin, APITestCase): response = self.client.get(self.ENDPOINT) 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): """ GIVEN: diff --git a/src/documents/tests/test_task_signals.py b/src/documents/tests/test_task_signals.py index 4a54220e0..a025fb9dc 100644 --- a/src/documents/tests/test_task_signals.py +++ b/src/documents/tests/test_task_signals.py @@ -5,6 +5,7 @@ import celery from django.test import TestCase from documents.data_models import ConsumableDocument +from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentSource from documents.models import PaperlessTask from documents.signals.handlers import before_task_publish_handler @@ -48,7 +49,10 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase): source=DocumentSource.ConsumeFolder, original_file="/consume/hello-999.pdf", ), - None, + DocumentMetadataOverrides( + title="Hello world", + owner_id=1, + ), ), # kwargs {}, @@ -65,6 +69,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase): self.assertEqual(headers["id"], task.task_id) self.assertEqual("hello-999.pdf", task.task_file_name) self.assertEqual("documents.tasks.consume_file", task.task_name) + self.assertEqual(1, task.owner_id) self.assertEqual(celery.states.PENDING, task.status) def test_task_prerun_handler(self): diff --git a/src/documents/views.py b/src/documents/views.py index 2d0c030f4..332d5f64a 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1705,6 +1705,7 @@ class RemoteVersionView(GenericAPIView): class TasksViewSet(ReadOnlyModelViewSet): permission_classes = (IsAuthenticated, PaperlessObjectPermissions) serializer_class = TasksViewSerializer + filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,) def get_queryset(self): queryset = ( @@ -1719,19 +1720,17 @@ class TasksViewSet(ReadOnlyModelViewSet): queryset = PaperlessTask.objects.filter(task_id=task_id) return queryset - -class AcknowledgeTasksView(GenericAPIView): - permission_classes = (IsAuthenticated,) - serializer_class = AcknowledgeTasksViewSerializer - - def post(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + @action(methods=["post"], detail=False) + def acknowledge(self, request): + serializer = AcknowledgeTasksViewSerializer(data=request.data) serializer.is_valid(raise_exception=True) - - tasks = serializer.validated_data.get("tasks") + task_ids = serializer.validated_data.get("tasks") 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, ) return Response({"result": result}) diff --git a/src/paperless/settings.py b/src/paperless/settings.py index e5f31800f..1a495de09 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -333,7 +333,7 @@ REST_FRAMEWORK = { "DEFAULT_VERSION": "1", # Make sure these are ordered and that the most recent version appears # last - "ALLOWED_VERSIONS": ["1", "2", "3", "4", "5"], + "ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6"], } if DEBUG: diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 2ebd7e739..5b7327b8d 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -18,7 +18,6 @@ from django.views.static import serve from rest_framework.authtoken import views from rest_framework.routers import DefaultRouter -from documents.views import AcknowledgeTasksView from documents.views import BulkDownloadView from documents.views import BulkEditObjectsView from documents.views import BulkEditView @@ -132,11 +131,6 @@ urlpatterns = [ name="remoteversion", ), re_path("^ui_settings/", UiSettingsView.as_view(), name="ui_settings"), - re_path( - "^acknowledge_tasks/", - AcknowledgeTasksView.as_view(), - name="acknowledge_tasks", - ), re_path( "^mail_accounts/test/", MailAccountTestView.as_view(),