Skip to content

Commit 3cdd897

Browse files
authored
feat(projectHistoryLogs): create project history logs when related objects change TASK-944 (#5223)
Create project history logs for changes to associated media files, rest services, and imported data.
1 parent 70a8673 commit 3cdd897

File tree

10 files changed

+521
-65
lines changed

10 files changed

+521
-65
lines changed

kobo/apps/audit_log/audit_actions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@
22

33

44
class AuditAction(models.TextChoices):
5+
ADD_MEDIA = 'add-media'
56
ARCHIVE = 'archive'
67
AUTH = 'auth'
8+
CONNECT_PROJECT = 'connect-project'
79
CREATE = 'create'
810
DELETE = 'delete'
11+
DELETE_MEDIA = 'delete-media'
12+
DELETE_SERVICE = 'delete-service'
913
DEPLOY = 'deploy'
1014
DISABLE_SHARING = 'disable-sharing'
15+
DISCONNECT_PROJECT = 'disconnect-project'
1116
ENABLE_SHARING = 'enable-sharing'
1217
IN_TRASH = 'in-trash'
18+
MODIFY_IMPORTED_FIELDS = 'modify-imported-fields'
19+
MODIFY_SERVICE = 'modify-service'
1320
MODIFY_SHARING = 'modify_sharing'
1421
PUT_BACK = 'put-back'
1522
REDEPLOY = 'redeploy'
23+
REGISTER_SERVICE = 'register-service'
1624
REMOVE = 'remove'
1725
UNARCHIVE = 'unarchive'
1826
UPDATE = 'update'

kobo/apps/audit_log/base_views.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,32 +49,40 @@ def get_object(self):
4949
return obj
5050
audit_log_data = {}
5151
for field in self.logged_fields:
52-
value = get_nested_field(obj, field)
53-
audit_log_data[field] = value
52+
field_path = field[1] if isinstance(field, tuple) else field
53+
field_label = field[0] if isinstance(field, tuple) else field
54+
value = get_nested_field(obj, field_path)
55+
audit_log_data[field_label] = value
5456
self.request._request.initial_data = audit_log_data
5557
return obj
5658

5759
def perform_update(self, serializer):
5860
self.perform_update_override(serializer)
5961
audit_log_data = {}
6062
for field in self.logged_fields:
61-
value = get_nested_field(serializer.instance, field)
62-
audit_log_data[field] = value
63+
field_path = field[1] if isinstance(field, tuple) else field
64+
field_label = field[0] if isinstance(field, tuple) else field
65+
value = get_nested_field(serializer.instance, field_path)
66+
audit_log_data[field_label] = value
6367
self.request._request.updated_data = audit_log_data
6468

6569
def perform_create(self, serializer):
6670
self.perform_create_override(serializer)
6771
audit_log_data = {}
6872
for field in self.logged_fields:
69-
value = get_nested_field(serializer.instance, field)
70-
audit_log_data[field] = value
73+
field_path = field[1] if isinstance(field, tuple) else field
74+
field_label = field[0] if isinstance(field, tuple) else field
75+
value = get_nested_field(serializer.instance, field_path)
76+
audit_log_data[field_label] = value
7177
self.request._request.updated_data = audit_log_data
7278

7379
def perform_destroy(self, instance):
7480
audit_log_data = {}
7581
for field in self.logged_fields:
76-
value = get_nested_field(instance, field)
77-
audit_log_data[field] = value
82+
field_path = field[1] if isinstance(field, tuple) else field
83+
field_label = field[0] if isinstance(field, tuple) else field
84+
value = get_nested_field(instance, field_path)
85+
audit_log_data[field_label] = value
7886
self.request._request.initial_data = audit_log_data
7987
self.perform_destroy_override(instance)
8088

kobo/apps/audit_log/models.py

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,21 @@ def save(
324324

325325
@classmethod
326326
def create_from_request(cls, request):
327-
if request.resolver_match.url_name == 'asset-deployment':
328-
cls.create_from_deployment_request(request)
329-
elif request.resolver_match.url_name == 'asset-detail':
330-
cls.create_from_detail_request(request)
327+
url_name_to_action = {
328+
'asset-deployment': cls.create_from_deployment_request,
329+
'asset-detail': cls.create_from_detail_request,
330+
'hook-detail': cls.create_from_hook_request,
331+
'hook-list': cls.create_from_hook_request,
332+
'paired-data-detail': cls.create_from_paired_data_request,
333+
'paired-data-list': cls.create_from_paired_data_request,
334+
'asset-file-detail': cls.create_from_file_request,
335+
'asset-file-list': cls.create_from_file_request,
336+
}
337+
url_name = request.resolver_match.url_name
338+
method = url_name_to_action.get(url_name, None)
339+
if not method:
340+
return
341+
method(request)
331342

332343
@staticmethod
333344
def create_from_deployment_request(request):
@@ -453,6 +464,33 @@ def settings_change(old_field, new_field):
453464
settings[setting_name] = metadata_field_subdict
454465
return AuditAction.UPDATE_SETTINGS, {'settings': settings}
455466

467+
@classmethod
468+
def create_from_hook_request(cls, request):
469+
cls.create_from_related_request(
470+
request,
471+
'hook',
472+
AuditAction.REGISTER_SERVICE,
473+
AuditAction.DELETE_SERVICE,
474+
AuditAction.MODIFY_SERVICE,
475+
)
476+
477+
@classmethod
478+
def create_from_file_request(cls, request):
479+
# we don't have a concept of 'modifying' a media file
480+
cls.create_from_related_request(
481+
request, 'asset-file', AuditAction.ADD_MEDIA, AuditAction.DELETE_MEDIA, None
482+
)
483+
484+
@classmethod
485+
def create_from_paired_data_request(cls, request):
486+
cls.create_from_related_request(
487+
request,
488+
'paired-data',
489+
AuditAction.CONNECT_PROJECT,
490+
AuditAction.DISCONNECT_PROJECT,
491+
AuditAction.MODIFY_IMPORTED_FIELDS,
492+
)
493+
456494
@staticmethod
457495
def sharing_change(old_fields, new_fields):
458496
old_enabled = old_fields.get('enabled', False)
@@ -493,3 +531,33 @@ def qa_change(_, new_field):
493531
# qa dictionary is complicated to parse and determine
494532
# what actually changed, so just return the new dict
495533
return AuditAction.UPDATE_QA, {'qa': {NEW: new_field}}
534+
535+
@staticmethod
536+
def create_from_related_request(
537+
request, label, add_action, delete_action, modify_action
538+
):
539+
initial_data = getattr(request, 'initial_data', None)
540+
updated_data = getattr(request, 'updated_data', None)
541+
asset_uid = request.resolver_match.kwargs['parent_lookup_asset']
542+
source_data = updated_data if updated_data else initial_data
543+
if not source_data:
544+
# request failed, don't try to log
545+
return
546+
object_id = source_data.pop('object_id')
547+
548+
metadata = {
549+
'asset_uid': asset_uid,
550+
'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE,
551+
'ip_address': get_client_ip(request),
552+
'source': get_human_readable_client_user_agent(request),
553+
label: source_data,
554+
}
555+
if updated_data is None:
556+
action = delete_action
557+
elif initial_data is None:
558+
action = add_action
559+
else:
560+
action = modify_action
561+
ProjectHistoryLog.objects.create(
562+
user=request.user, object_id=object_id, action=action, metadata=metadata
563+
)

kobo/apps/audit_log/tests/test_models.py

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import datetime
22
from datetime import timedelta
3-
from unittest.mock import patch
3+
from unittest.mock import Mock, patch
44

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

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

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

440438
with self.assertRaises(ValidationError):
441439
ProjectHistoryLog.objects.create(
442440
object_id=asset.id,
443441
metadata=metadata,
444442
user=user,
445443
)
444+
445+
# remove key
446+
filtered = {k: v for k, v in metadata.items() if v is not None}
447+
with self.assertRaises(ValidationError):
448+
ProjectHistoryLog.objects.create(
449+
object_id=asset.id,
450+
metadata=filtered,
451+
user=user,
452+
)
453+
454+
def test_create_from_related_request_object_created(self):
455+
factory = RequestFactory()
456+
request = factory.post('/')
457+
request.user = User.objects.get(username='someuser')
458+
request.resolver_match = Mock()
459+
request.resolver_match.kwargs = {'parent_lookup_asset': 'a12345'}
460+
# if an object has been created, only `updated_data` will be set
461+
request.updated_data = {
462+
'object_id': 1,
463+
'field_1': 'a',
464+
'field_2': 'b',
465+
}
466+
ProjectHistoryLog.create_from_related_request(
467+
request,
468+
label='fieldname',
469+
add_action=AuditAction.CREATE,
470+
delete_action=AuditAction.DELETE,
471+
modify_action=AuditAction.UPDATE,
472+
)
473+
log = ProjectHistoryLog.objects.first()
474+
self.assertEqual(log.action, AuditAction.CREATE)
475+
self.assertEqual(log.object_id, 1)
476+
# metadata should contain all additional fields that were stored in updated_data
477+
# under the given label
478+
self.assertDictEqual(
479+
log.metadata['fieldname'], {'field_1': 'a', 'field_2': 'b'}
480+
)
481+
self.assertEqual(log.metadata['asset_uid'], 'a12345')
482+
483+
def test_create_from_related_request_object_deleted(self):
484+
factory = RequestFactory()
485+
request = factory.post('/')
486+
request.user = User.objects.get(username='someuser')
487+
request.resolver_match = Mock()
488+
request.resolver_match.kwargs = {'parent_lookup_asset': 'a12345'}
489+
# if an object has been created, only `initial_data` will be set
490+
request.initial_data = {
491+
'object_id': 1,
492+
'field_1': 'a',
493+
'field_2': 'b',
494+
}
495+
ProjectHistoryLog.create_from_related_request(
496+
request,
497+
label='label',
498+
add_action=AuditAction.CREATE,
499+
delete_action=AuditAction.DELETE,
500+
modify_action=AuditAction.UPDATE,
501+
)
502+
log = ProjectHistoryLog.objects.first()
503+
self.assertEqual(log.action, AuditAction.DELETE)
504+
self.assertEqual(log.object_id, 1)
505+
# metadata should contain all additional fields that were stored in updated_data
506+
# under the given label
507+
self.assertDictEqual(log.metadata['label'], {'field_1': 'a', 'field_2': 'b'})
508+
self.assertEqual(log.metadata['asset_uid'], 'a12345')
509+
510+
def test_create_from_related_request_object_modified(self):
511+
factory = RequestFactory()
512+
request = factory.post('/')
513+
request.user = User.objects.get(username='someuser')
514+
request.resolver_match = Mock()
515+
request.resolver_match.kwargs = {'parent_lookup_asset': 'a12345'}
516+
# if an object has been modified, both `initial_data`
517+
# and `updated_data` should be filled
518+
request.initial_data = {
519+
'object_id': 1,
520+
'field_1': 'a',
521+
'field_2': 'b',
522+
}
523+
request.updated_data = {
524+
'object_id': 1,
525+
'field_1': 'new_field1',
526+
'field_2': 'new_field2',
527+
}
528+
ProjectHistoryLog.create_from_related_request(
529+
request,
530+
label='label',
531+
add_action=AuditAction.CREATE,
532+
delete_action=AuditAction.DELETE,
533+
modify_action=AuditAction.UPDATE,
534+
)
535+
log = ProjectHistoryLog.objects.first()
536+
self.assertEqual(log.action, AuditAction.UPDATE)
537+
self.assertEqual(log.object_id, 1)
538+
# we should use the updated data for the log
539+
self.assertDictEqual(
540+
log.metadata['label'], {'field_1': 'new_field1', 'field_2': 'new_field2'}
541+
)
542+
self.assertEqual(log.metadata['asset_uid'], 'a12345')
543+
544+
def test_create_from_related_request_no_log_created_if_no_data(self):
545+
factory = RequestFactory()
546+
request = factory.post('/')
547+
request.user = User.objects.get(username='someuser')
548+
request.resolver_match = Mock()
549+
request.resolver_match.kwargs = {'parent_lookup_asset': 'a12345'}
550+
# no `initial_data` or `updated_data` present
551+
ProjectHistoryLog.create_from_related_request(
552+
request,
553+
label='label',
554+
add_action=AuditAction.CREATE,
555+
delete_action=AuditAction.DELETE,
556+
modify_action=AuditAction.UPDATE,
557+
)
558+
self.assertEqual(ProjectHistoryLog.objects.count(), 0)

0 commit comments

Comments
 (0)