From 16b2fbcfc8981b9b8300661a69eb0b46c8ca093a Mon Sep 17 00:00:00 2001 From: Rebecca Graber Date: Tue, 3 Dec 2024 13:22:17 -0500 Subject: [PATCH] feat(projectHistoryLogs): record logs for transfering ownership TASK-944 (#5313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### đŸ“Ŗ Summary Record project history logs for project ownership transfers. ### 👀 Preview steps Feature/no-change template: 1. ℹī¸ Have at least 2 users and a project 2. Go to Project > Settings > Sharing 3. Transfer ownership to user2 4. Go to `http://localhost/api/v2/audit-logs/?q=log_type:project-history AND metadata__asset_uid:&format=json` 6. đŸŸĸ There should be a new log with action="transfer" and the usual metadata, plus an additional `metadata.username = "user2"` field ### 💭 Notes This does not rely on the transfer being successful/accepted. We want to log any attempt to transfer. Canceled requests will be handled in v2. --- kobo/apps/audit_log/audit_actions.py | 1 + kobo/apps/audit_log/models.py | 32 ++++++++++ .../tests/test_project_history_logs.py | 58 +++++++++++++++++++ kobo/apps/project_ownership/views/invite.py | 6 +- 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/kobo/apps/audit_log/audit_actions.py b/kobo/apps/audit_log/audit_actions.py index 9fabce4186..f15b91c17e 100644 --- a/kobo/apps/audit_log/audit_actions.py +++ b/kobo/apps/audit_log/audit_actions.py @@ -38,3 +38,4 @@ class AuditAction(models.TextChoices): UPDATE_NAME = 'update-name' UPDATE_SETTINGS = 'update-settings' UPDATE_QA = 'update-qa' + TRANSFER = 'transfer' diff --git a/kobo/apps/audit_log/models.py b/kobo/apps/audit_log/models.py index 09caa86a74..54379cf55c 100644 --- a/kobo/apps/audit_log/models.py +++ b/kobo/apps/audit_log/models.py @@ -21,6 +21,7 @@ ACCESS_LOG_SUBMISSION_AUTH_TYPE, ACCESS_LOG_SUBMISSION_GROUP_AUTH_TYPE, ACCESS_LOG_UNKNOWN_AUTH_TYPE, + ASSET_TYPE_SURVEY, CLONE_ARG_NAME, PERM_ADD_SUBMISSIONS, PERM_VIEW_ASSET, @@ -356,6 +357,7 @@ def create_from_request(cls, request): 'asset-permission-assignment-detail': cls.create_from_permissions_request, 'asset-permission-assignment-list': cls.create_from_permissions_request, 'asset-permission-assignment-clone': cls.handle_cloned_permissions, + 'project-ownership-invite-list': cls.handle_ownership_transfer, } url_name = request.resolver_match.url_name method = url_name_to_action.get(url_name, None) @@ -823,3 +825,33 @@ def handle_cloned_permissions(cls, request): 'cloned_from': request._data[CLONE_ARG_NAME], }, ) + + @classmethod + def handle_ownership_transfer(cls, request): + updated_data = getattr(request, 'updated_data') + transfers = updated_data['transfers'].values( + 'asset__uid', 'asset__asset_type', 'asset__id' + ) + logs = [] + for transfer in transfers: + if transfer['asset__asset_type'] != ASSET_TYPE_SURVEY: + continue + logs.append( + ProjectHistoryLog( + object_id=transfer['asset__id'], + action=AuditAction.TRANSFER, + user=request.user, + app_label=Asset._meta.app_label, + model_name=Asset._meta.model_name, + log_type=AuditType.PROJECT_HISTORY, + user_uid=request.user.extra_details.uid, + metadata={ + 'asset_uid': transfer['asset__uid'], + 'log_subtype': PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE, + 'ip_address': get_client_ip(request), + 'source': get_human_readable_client_user_agent(request), + 'username': updated_data['recipient.username'], + }, + ) + ) + ProjectHistoryLog.objects.bulk_create(logs) diff --git a/kobo/apps/audit_log/tests/test_project_history_logs.py b/kobo/apps/audit_log/tests/test_project_history_logs.py index 8ff52c11b0..47e4c4b3f1 100644 --- a/kobo/apps/audit_log/tests/test_project_history_logs.py +++ b/kobo/apps/audit_log/tests/test_project_history_logs.py @@ -17,6 +17,7 @@ from kobo.apps.hook.models import Hook from kobo.apps.kobo_auth.shortcuts import User from kpi.constants import ( + ASSET_TYPE_TEMPLATE, CLONE_ARG_NAME, PERM_ADD_SUBMISSIONS, PERM_CHANGE_SUBMISSIONS, @@ -1302,3 +1303,60 @@ def test_clone_permissions_creates_logs(self): expected_subtype=PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE, ) self.assertEqual(log_metadata['cloned_from'], second_asset.uid) + + def test_transfer_creates_log(self): + log_metadata = self._base_project_history_log_test( + method=self.client.post, + url=reverse( + 'api_v2:project-ownership-invite-list', + ), + request_data={ + 'recipient': reverse( + 'api_v2:user-kpi-detail', kwargs={'username': 'someuser'} + ), + 'assets': [self.asset.uid], + }, + expected_action=AuditAction.TRANSFER, + expected_subtype=PROJECT_HISTORY_LOG_PERMISSION_SUBTYPE, + ) + self.assertEqual(log_metadata['username'], 'someuser') + + def test_transfer_multiple_creates_logs(self): + second_asset = Asset.objects.get(pk=2) + # make admin the owner of the other asset so we can transfer it + second_asset.owner = self.user + second_asset.save() + self.client.post( + path=reverse('api_v2:project-ownership-invite-list'), + data={ + 'recipient': reverse( + 'api_v2:user-kpi-detail', kwargs={'username': 'someuser'} + ), + 'assets': [self.asset.uid, second_asset.uid], + }, + ) + self.assertEqual(ProjectHistoryLog.objects.count(), 2) + first_log = ProjectHistoryLog.objects.filter( + metadata__asset_uid=self.asset.uid, action=AuditAction.TRANSFER + ) + self.assertTrue(first_log.exists()) + second_log = ProjectHistoryLog.objects.filter( + metadata__asset_uid=second_asset.uid, action=AuditAction.TRANSFER + ) + self.assertTrue(second_log.exists()) + + def test_no_log_created_for_non_project_transfer(self): + new_asset = Asset.objects.create( + owner=self.user, + asset_type=ASSET_TYPE_TEMPLATE, + ) + self.client.post( + path=reverse('api_v2:project-ownership-invite-list'), + data={ + 'recipient': reverse( + 'api_v2:user-kpi-detail', kwargs={'username': 'someuser'} + ), + 'assets': [new_asset.uid], + }, + ) + self.assertEqual(ProjectHistoryLog.objects.count(), 0) diff --git a/kobo/apps/project_ownership/views/invite.py b/kobo/apps/project_ownership/views/invite.py index 71126c8692..f74a40064d 100644 --- a/kobo/apps/project_ownership/views/invite.py +++ b/kobo/apps/project_ownership/views/invite.py @@ -1,12 +1,12 @@ -from rest_framework import viewsets from kpi.permissions import IsAuthenticated +from ...audit_log.base_views import AuditLoggedModelViewSet from ..filters import InviteFilter from ..models import Invite from ..serializers import InviteSerializer -class InviteViewSet(viewsets.ModelViewSet): +class InviteViewSet(AuditLoggedModelViewSet): """ ## List of invites @@ -225,6 +225,8 @@ class InviteViewSet(viewsets.ModelViewSet): serializer_class = InviteSerializer permission_classes = (IsAuthenticated,) filter_backends = (InviteFilter, ) + log_type = 'project-history' + logged_fields = ['recipient.username', 'status', 'transfers'] def get_queryset(self):