Skip to content

Commit

Permalink
feat(projectHistoryLogs): create project history logs when related ob…
Browse files Browse the repository at this point in the history
…jects change TASK-944 (#5223)

Create project history logs for changes to associated media files, rest
services, and imported data.
  • Loading branch information
rgraber authored Nov 6, 2024
1 parent 70a8673 commit 3cdd897
Show file tree
Hide file tree
Showing 10 changed files with 521 additions and 65 deletions.
8 changes: 8 additions & 0 deletions kobo/apps/audit_log/audit_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@


class AuditAction(models.TextChoices):
ADD_MEDIA = 'add-media'
ARCHIVE = 'archive'
AUTH = 'auth'
CONNECT_PROJECT = 'connect-project'
CREATE = 'create'
DELETE = 'delete'
DELETE_MEDIA = 'delete-media'
DELETE_SERVICE = 'delete-service'
DEPLOY = 'deploy'
DISABLE_SHARING = 'disable-sharing'
DISCONNECT_PROJECT = 'disconnect-project'
ENABLE_SHARING = 'enable-sharing'
IN_TRASH = 'in-trash'
MODIFY_IMPORTED_FIELDS = 'modify-imported-fields'
MODIFY_SERVICE = 'modify-service'
MODIFY_SHARING = 'modify_sharing'
PUT_BACK = 'put-back'
REDEPLOY = 'redeploy'
REGISTER_SERVICE = 'register-service'
REMOVE = 'remove'
UNARCHIVE = 'unarchive'
UPDATE = 'update'
Expand Down
24 changes: 16 additions & 8 deletions kobo/apps/audit_log/base_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,32 +49,40 @@ def get_object(self):
return obj
audit_log_data = {}
for field in self.logged_fields:
value = get_nested_field(obj, field)
audit_log_data[field] = value
field_path = field[1] if isinstance(field, tuple) else field
field_label = field[0] if isinstance(field, tuple) else field
value = get_nested_field(obj, field_path)
audit_log_data[field_label] = value
self.request._request.initial_data = audit_log_data
return obj

def perform_update(self, serializer):
self.perform_update_override(serializer)
audit_log_data = {}
for field in self.logged_fields:
value = get_nested_field(serializer.instance, field)
audit_log_data[field] = value
field_path = field[1] if isinstance(field, tuple) else field
field_label = field[0] if isinstance(field, tuple) else field
value = get_nested_field(serializer.instance, field_path)
audit_log_data[field_label] = value
self.request._request.updated_data = audit_log_data

def perform_create(self, serializer):
self.perform_create_override(serializer)
audit_log_data = {}
for field in self.logged_fields:
value = get_nested_field(serializer.instance, field)
audit_log_data[field] = value
field_path = field[1] if isinstance(field, tuple) else field
field_label = field[0] if isinstance(field, tuple) else field
value = get_nested_field(serializer.instance, field_path)
audit_log_data[field_label] = value
self.request._request.updated_data = audit_log_data

def perform_destroy(self, instance):
audit_log_data = {}
for field in self.logged_fields:
value = get_nested_field(instance, field)
audit_log_data[field] = value
field_path = field[1] if isinstance(field, tuple) else field
field_label = field[0] if isinstance(field, tuple) else field
value = get_nested_field(instance, field_path)
audit_log_data[field_label] = value
self.request._request.initial_data = audit_log_data
self.perform_destroy_override(instance)

Expand Down
76 changes: 72 additions & 4 deletions kobo/apps/audit_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,21 @@ def save(

@classmethod
def create_from_request(cls, request):
if request.resolver_match.url_name == 'asset-deployment':
cls.create_from_deployment_request(request)
elif request.resolver_match.url_name == 'asset-detail':
cls.create_from_detail_request(request)
url_name_to_action = {
'asset-deployment': cls.create_from_deployment_request,
'asset-detail': cls.create_from_detail_request,
'hook-detail': cls.create_from_hook_request,
'hook-list': cls.create_from_hook_request,
'paired-data-detail': cls.create_from_paired_data_request,
'paired-data-list': cls.create_from_paired_data_request,
'asset-file-detail': cls.create_from_file_request,
'asset-file-list': cls.create_from_file_request,
}
url_name = request.resolver_match.url_name
method = url_name_to_action.get(url_name, None)
if not method:
return
method(request)

@staticmethod
def create_from_deployment_request(request):
Expand Down Expand Up @@ -453,6 +464,33 @@ def settings_change(old_field, new_field):
settings[setting_name] = metadata_field_subdict
return AuditAction.UPDATE_SETTINGS, {'settings': settings}

@classmethod
def create_from_hook_request(cls, request):
cls.create_from_related_request(
request,
'hook',
AuditAction.REGISTER_SERVICE,
AuditAction.DELETE_SERVICE,
AuditAction.MODIFY_SERVICE,
)

@classmethod
def create_from_file_request(cls, request):
# we don't have a concept of 'modifying' a media file
cls.create_from_related_request(
request, 'asset-file', AuditAction.ADD_MEDIA, AuditAction.DELETE_MEDIA, None
)

@classmethod
def create_from_paired_data_request(cls, request):
cls.create_from_related_request(
request,
'paired-data',
AuditAction.CONNECT_PROJECT,
AuditAction.DISCONNECT_PROJECT,
AuditAction.MODIFY_IMPORTED_FIELDS,
)

@staticmethod
def sharing_change(old_fields, new_fields):
old_enabled = old_fields.get('enabled', False)
Expand Down Expand Up @@ -493,3 +531,33 @@ def qa_change(_, new_field):
# qa dictionary is complicated to parse and determine
# what actually changed, so just return the new dict
return AuditAction.UPDATE_QA, {'qa': {NEW: new_field}}

@staticmethod
def create_from_related_request(
request, label, add_action, delete_action, modify_action
):
initial_data = getattr(request, 'initial_data', None)
updated_data = getattr(request, 'updated_data', None)
asset_uid = request.resolver_match.kwargs['parent_lookup_asset']
source_data = updated_data if updated_data else initial_data
if not source_data:
# request failed, don't try to log
return
object_id = source_data.pop('object_id')

metadata = {
'asset_uid': asset_uid,
'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE,
'ip_address': get_client_ip(request),
'source': get_human_readable_client_user_agent(request),
label: source_data,
}
if updated_data is None:
action = delete_action
elif initial_data is None:
action = add_action
else:
action = modify_action
ProjectHistoryLog.objects.create(
user=request.user, object_id=object_id, action=action, metadata=metadata
)
123 changes: 118 additions & 5 deletions kobo/apps/audit_log/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime
from datetime import timedelta
from unittest.mock import patch
from unittest.mock import Mock, patch

from ddt import data, ddt, unpack
from django.contrib.auth.models import AnonymousUser
Expand Down Expand Up @@ -96,7 +96,7 @@ def test_create_access_log_ignores_attempt_to_override_standard_fields(
# the standard fields should be set the same as any other access logs
self._check_common_fields(log, AccessLogModelTestCase.super_user)
# we logged a warning for each attempt to override a field
self.assertEquals(patched_warning.call_count, 4)
self.assertEqual(patched_warning.call_count, 4)

def test_basic_create_auth_log_from_request(self):
request = self._create_request(
Expand Down Expand Up @@ -412,7 +412,7 @@ def test_create_project_history_log_ignores_attempt_to_override_standard_fields(
# the standard fields should be set the same as any other project history logs
self._check_common_fields(log, user, asset)
# we logged a warning for each attempt to override a field
self.assertEquals(patched_warning.call_count, 3)
self.assertEqual(patched_warning.call_count, 3)

@data(
# source, asset_uid, ip_address, subtype
Expand All @@ -434,12 +434,125 @@ def test_create_project_history_log_requires_metadata_fields(
'asset_uid': asset_uid,
'log_subtype': subtype,
}
# remove whatever we set to None
# filtered = { k:v for k,v in metadata.items() if v is not None }

with self.assertRaises(ValidationError):
ProjectHistoryLog.objects.create(
object_id=asset.id,
metadata=metadata,
user=user,
)

# remove key
filtered = {k: v for k, v in metadata.items() if v is not None}
with self.assertRaises(ValidationError):
ProjectHistoryLog.objects.create(
object_id=asset.id,
metadata=filtered,
user=user,
)

def test_create_from_related_request_object_created(self):
factory = RequestFactory()
request = factory.post('/')
request.user = User.objects.get(username='someuser')
request.resolver_match = Mock()
request.resolver_match.kwargs = {'parent_lookup_asset': 'a12345'}
# if an object has been created, only `updated_data` will be set
request.updated_data = {
'object_id': 1,
'field_1': 'a',
'field_2': 'b',
}
ProjectHistoryLog.create_from_related_request(
request,
label='fieldname',
add_action=AuditAction.CREATE,
delete_action=AuditAction.DELETE,
modify_action=AuditAction.UPDATE,
)
log = ProjectHistoryLog.objects.first()
self.assertEqual(log.action, AuditAction.CREATE)
self.assertEqual(log.object_id, 1)
# metadata should contain all additional fields that were stored in updated_data
# under the given label
self.assertDictEqual(
log.metadata['fieldname'], {'field_1': 'a', 'field_2': 'b'}
)
self.assertEqual(log.metadata['asset_uid'], 'a12345')

def test_create_from_related_request_object_deleted(self):
factory = RequestFactory()
request = factory.post('/')
request.user = User.objects.get(username='someuser')
request.resolver_match = Mock()
request.resolver_match.kwargs = {'parent_lookup_asset': 'a12345'}
# if an object has been created, only `initial_data` will be set
request.initial_data = {
'object_id': 1,
'field_1': 'a',
'field_2': 'b',
}
ProjectHistoryLog.create_from_related_request(
request,
label='label',
add_action=AuditAction.CREATE,
delete_action=AuditAction.DELETE,
modify_action=AuditAction.UPDATE,
)
log = ProjectHistoryLog.objects.first()
self.assertEqual(log.action, AuditAction.DELETE)
self.assertEqual(log.object_id, 1)
# metadata should contain all additional fields that were stored in updated_data
# under the given label
self.assertDictEqual(log.metadata['label'], {'field_1': 'a', 'field_2': 'b'})
self.assertEqual(log.metadata['asset_uid'], 'a12345')

def test_create_from_related_request_object_modified(self):
factory = RequestFactory()
request = factory.post('/')
request.user = User.objects.get(username='someuser')
request.resolver_match = Mock()
request.resolver_match.kwargs = {'parent_lookup_asset': 'a12345'}
# if an object has been modified, both `initial_data`
# and `updated_data` should be filled
request.initial_data = {
'object_id': 1,
'field_1': 'a',
'field_2': 'b',
}
request.updated_data = {
'object_id': 1,
'field_1': 'new_field1',
'field_2': 'new_field2',
}
ProjectHistoryLog.create_from_related_request(
request,
label='label',
add_action=AuditAction.CREATE,
delete_action=AuditAction.DELETE,
modify_action=AuditAction.UPDATE,
)
log = ProjectHistoryLog.objects.first()
self.assertEqual(log.action, AuditAction.UPDATE)
self.assertEqual(log.object_id, 1)
# we should use the updated data for the log
self.assertDictEqual(
log.metadata['label'], {'field_1': 'new_field1', 'field_2': 'new_field2'}
)
self.assertEqual(log.metadata['asset_uid'], 'a12345')

def test_create_from_related_request_no_log_created_if_no_data(self):
factory = RequestFactory()
request = factory.post('/')
request.user = User.objects.get(username='someuser')
request.resolver_match = Mock()
request.resolver_match.kwargs = {'parent_lookup_asset': 'a12345'}
# no `initial_data` or `updated_data` present
ProjectHistoryLog.create_from_related_request(
request,
label='label',
add_action=AuditAction.CREATE,
delete_action=AuditAction.DELETE,
modify_action=AuditAction.UPDATE,
)
self.assertEqual(ProjectHistoryLog.objects.count(), 0)
Loading

0 comments on commit 3cdd897

Please sign in to comment.