Skip to content

Commit 36b4484

Browse files
authored
feat(projectHistoryLog): record submission modifications (#5404)
### 📣 Summary Log updates to submissions, both content and validation status. ### 💭 Notes #### SubmissionUpdate The `SubmissionUpdate` class is just a dataclass meant to make it clear what information we need to create a PH log for submission modification/creation/deletion. It's meant mostly to help future developers since we're not using the usual `initial_data`/`updated_data` pattern where we just take configured fields off of a model. #### ProjectHistoryLog.__init__ This PR includes a small refactor on ProjectHistoryLog to set the `app_name`, `model_name`, and `log_type` in the `__init__` method like we do with `create`. This saves us some repeated code when we are creating logs in bulk. #### Attaching the asset to the request We always need the `asset_uid` when logging any views that include the `AssetNestedObjectViewsetMixin` mixin, so I updated `AuditLoggedViewSet` to always attach the asset (available via the mixin) to the request. We may be able to use this to simplify other code later if we wish. #### Where we add data to the request We can't use the usual method of having the views inherit from `AuditLoggedViewSet` and configuring `logged_fields` on the relevant model because the viewsets that update Instances rarely act directly on the Instance objects via get_object or the serializers. Instead they usually have custom code that works through the deployment object on the asset. We could pass the request from the viewset through the layers of other methods until we get to the part where we are actually updating instances, but this would require a lot of changes in existing view code and we're trying to keep the audit log code relatively self-contained. Changes to logging code should ideally not require changes in view code. Instead, I decided to use a receiver on the Instance model `post_save` signal. This also should allow the code to be more backend-agnostic since we did not update anything directly in the OpenRosaDeploymentBackend model, where most of the actual updating takes place. Theoretically, any backend would have to eventually update the Instance model with the new `xml` and `status` fields, regardless of how else it went about performing the updates. This is an assumption we have to make since we don't have any other "real" backend implementations, but it seems a safe one. The exception to this is when updating multiple validations statuses, because that is done with a bulk update, which doesn't send the `post_save` signal. In that bit of code, we have to manually attach the updated instances to the request. It's not considered great practice to get the request as part of model code but the alternative once again requires a significant refactor to pass the request through multiple layers. ### 👀 Preview steps Feature/no-change template: 1. Regression test: follow the test plan at #5387 to make sure that logging for duplicate submissions still works since that changed. 1. ℹ️ have at least 2 accounts and a project. Make sure both accounts have permission to add submissions to the project. For this preview, assume user1 owns the project. 2. Add a submission as the owner 3. Log in as user2 and add a submission. You may want to do this in a separate private tab, otherwise sometimes the enketo login stays cached and the submissions end up attached to the first user. 4. Login as user1 again and enable submissions without username or email 5. Add one more submission. It should come in as anonymous (ie no _submitted_by) 6. Go to the My Project > Data. 7. Change the validation status of a user1's submission to 'On Hold' using the dropdown in the table. 8. Go to `/api/v2/assets/<asset_uid>/history` 9. 🟢 There should be a new project history log with `action="modify-submission"` and the usual metadata, plus ``` "submission": { "status": "On Hold", "submitted_by": "user1" } ``` 10. Go back to the table and select all 3 submissions. 11. Select 'Change status' and update the status to 'Approved' for all of them 12. 🟢 Reload the endpoint. There should be 3 new logs with `action="modify-submission"` and the usual metadata, plus ``` "submission": { "status": "Approved", "submitted_by": "user1/user2/AnonymousUser" } ``` Note: there should be one log each for the different users since they each have one submission 13. Go back to the table and click the pencil by user2's submission. Edit an answer to a question. 14. 🟢 Reload the endpoint. There should be a new log with `action="modify-submission"` and the usual metadata, plus ``` "submission": { "submitted_by": "user2" } ``` Note there is no validation status since that did not change. 15. Go back to the table and select all submissions. 16. Hit `Edit` and edit the answer to a question 17. 🟢 Reload the endpoint. There should be a 3 new logs with `action="modify-submission"` and the usual metadata, plus ``` "submission": { "submitted_by": "user1/user2/AnonymousUser" } ```
1 parent 0666f7b commit 36b4484

File tree

11 files changed

+380
-59
lines changed

11 files changed

+380
-59
lines changed

kobo/apps/audit_log/audit_actions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class AuditAction(models.TextChoices):
1313
DELETE = 'delete'
1414
DELETE_MEDIA = 'delete-media'
1515
DELETE_SERVICE = 'delete-service'
16+
DELETE_SUBMISSION = 'delete-submission'
1617
DEPLOY = 'deploy'
1718
DISABLE_SHARING = 'disable-sharing'
1819
DISALLOW_ANONYMOUS_SUBMISSIONS = 'disallow-anonymous-submissions'
@@ -23,6 +24,7 @@ class AuditAction(models.TextChoices):
2324
MODIFY_IMPORTED_FIELDS = 'modify-imported-fields'
2425
MODIFY_SERVICE = 'modify-service'
2526
MODIFY_SHARING = 'modify-sharing'
27+
MODIFY_SUBMISSION = 'modify-submission'
2628
MODIFY_USER_PERMISSIONS = 'modify-user-permissions'
2729
PUT_BACK = 'put-back'
2830
REDEPLOY = 'redeploy'

kobo/apps/audit_log/base_views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from rest_framework import mixins, viewsets
22

3+
from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin
4+
35

46
def get_nested_field(obj, field: str):
57
"""
@@ -40,6 +42,8 @@ def initialize_request(self, request, *args, **kwargs):
4042
request = super().initialize_request(request, *args, **kwargs)
4143
request._request.log_type = self.log_type
4244
request._request._data = request.data.copy()
45+
if isinstance(self, AssetNestedObjectViewsetMixin):
46+
request._request.asset = self.asset
4347
return request
4448

4549
def get_object(self):
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Generated by Django 4.2.15 on 2025-01-01 17:33
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('audit_log', '0014_add_submission_action'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='auditlog',
15+
name='action',
16+
field=models.CharField(
17+
choices=[
18+
('add-media', 'Add Media'),
19+
('add-submission', 'Add Submission'),
20+
('allow-anonymous-submissions', 'Allow Anonymous Submissions'),
21+
('archive', 'Archive'),
22+
('auth', 'Auth'),
23+
('clone-permissions', 'Clone Permissions'),
24+
('connect-project', 'Connect Project'),
25+
('create', 'Create'),
26+
('delete', 'Delete'),
27+
('delete-media', 'Delete Media'),
28+
('delete-service', 'Delete Service'),
29+
('delete-submission', 'Delete Submission'),
30+
('deploy', 'Deploy'),
31+
('disable-sharing', 'Disable Sharing'),
32+
(
33+
'disallow-anonymous-submissions',
34+
'Disallow Anonymous Submissions',
35+
),
36+
('disconnect-project', 'Disconnect Project'),
37+
('enable-sharing', 'Enable Sharing'),
38+
('export', 'Export'),
39+
('in-trash', 'In Trash'),
40+
('modify-imported-fields', 'Modify Imported Fields'),
41+
('modify-service', 'Modify Service'),
42+
('modify-sharing', 'Modify Sharing'),
43+
('modify-submission', 'Modify Submission'),
44+
('modify-user-permissions', 'Modify User Permissions'),
45+
('put-back', 'Put Back'),
46+
('redeploy', 'Redeploy'),
47+
('register-service', 'Register Service'),
48+
('remove', 'Remove'),
49+
('replace-form', 'Replace Form'),
50+
('share-form-publicly', 'Share Form Publicly'),
51+
('share-data-publicly', 'Share Data Publicly'),
52+
('unarchive', 'Unarchive'),
53+
('unshare-form-publicly', 'Unshare Form Publicly'),
54+
('unshare-data-publicly', 'Unshare Data Publicly'),
55+
('update', 'Update'),
56+
('update-content', 'Update Content'),
57+
('update-name', 'Update Name'),
58+
('update-settings', 'Update Settings'),
59+
('update-qa', 'Update Qa'),
60+
('transfer', 'Transfer'),
61+
],
62+
db_index=True,
63+
default='delete',
64+
max_length=30,
65+
),
66+
),
67+
]

kobo/apps/audit_log/models.py

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from kobo.apps.audit_log.audit_log_metadata_schemas import (
1313
PROJECT_HISTORY_LOG_METADATA_SCHEMA,
1414
)
15+
from kobo.apps.audit_log.utils import SubmissionUpdate
1516
from kobo.apps.kobo_auth.shortcuts import User
1617
from kobo.apps.openrosa.libs.utils.viewer_tools import (
1718
get_client_ip,
@@ -293,6 +294,7 @@ def create_from_request(
293294

294295

295296
class ProjectHistoryLogManager(models.Manager, IgnoreCommonFieldsMixin):
297+
296298
def get_queryset(self):
297299
return super().get_queryset().filter(log_type=AuditType.PROJECT_HISTORY)
298300

@@ -323,6 +325,12 @@ class ProjectHistoryLog(AuditLog):
323325
class Meta:
324326
proxy = True
325327

328+
def __init__(self, *args, **kwargs):
329+
super().__init__(*args, **kwargs)
330+
self.app_label = Asset._meta.app_label
331+
self.model_name = Asset._meta.model_name
332+
self.log_type = AuditType.PROJECT_HISTORY
333+
326334
@classmethod
327335
def create_from_import_task(cls, task: ImportTask):
328336
# this will probably only ever be a list of size 1 or 0,
@@ -383,7 +391,11 @@ def create_from_request(cls, request: WSGIRequest):
383391
'asset-permission-assignment-list': cls._create_from_permissions_request,
384392
'asset-permission-assignment-clone': cls._create_from_clone_permission_request, # noqa
385393
'project-ownership-invite-list': cls._create_from_ownership_transfer,
386-
'submission-duplicate': cls._create_from_duplicate_request,
394+
'submission-duplicate': cls._create_from_submission_request,
395+
'submission-bulk': cls._create_from_submission_request,
396+
'submission-validation-statuses': cls._create_from_submission_request,
397+
'submission-validation-status': cls._create_from_submission_request,
398+
'assetsnapshot-submission-alias': cls._create_from_submission_request,
387399
}
388400
url_name = request.resolver_match.url_name
389401
method = url_name_to_action.get(url_name, None)
@@ -568,26 +580,6 @@ def _create_from_detail_request(cls, request):
568580
metadata=full_metadata,
569581
)
570582

571-
@classmethod
572-
def _create_from_duplicate_request(cls, request):
573-
asset_uid = request.resolver_match.kwargs['parent_lookup_asset']
574-
updated_data = request.updated_data
575-
metadata = {
576-
'ip_address': get_client_ip(request),
577-
'source': get_human_readable_client_user_agent(request),
578-
'asset_uid': asset_uid,
579-
'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE,
580-
'submission': {
581-
'submitted_by': updated_data['submitted_by']
582-
}
583-
}
584-
ProjectHistoryLog.objects.create(
585-
object_id=updated_data['asset_id'],
586-
action=AuditAction.ADD_SUBMISSION,
587-
user=request.user,
588-
metadata=metadata
589-
)
590-
591583
@classmethod
592584
def _create_from_export_request(cls, request):
593585
cls._related_request_base(request, None, AuditAction.EXPORT, None, None)
@@ -609,6 +601,42 @@ def _create_from_hook_request(cls, request):
609601
AuditAction.MODIFY_SERVICE,
610602
)
611603

604+
@classmethod
605+
def _create_from_submission_request(cls, request):
606+
if request.method in ['GET', 'HEAD']:
607+
return
608+
instances: dict[int:SubmissionUpdate] = getattr(request, 'instances', {})
609+
logs = []
610+
url_name = request.resolver_match.url_name
611+
612+
for instance in instances.values():
613+
if instance.action == 'add':
614+
action = AuditAction.ADD_SUBMISSION
615+
else:
616+
action = AuditAction.MODIFY_SUBMISSION
617+
metadata = {
618+
'asset_uid': request.asset.uid,
619+
'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE,
620+
'ip_address': get_client_ip(request),
621+
'source': get_human_readable_client_user_agent(request),
622+
'submission': {
623+
'submitted_by': instance.username,
624+
},
625+
}
626+
if 'validation-status' in url_name:
627+
metadata['submission']['status'] = instance.status
628+
629+
logs.append(
630+
ProjectHistoryLog(
631+
user=request.user,
632+
object_id=request.asset.id,
633+
action=action,
634+
user_uid=request.user.extra_details.uid,
635+
metadata=metadata,
636+
)
637+
)
638+
ProjectHistoryLog.objects.bulk_create(logs)
639+
612640
@classmethod
613641
def _create_from_ownership_transfer(cls, request):
614642
updated_data = getattr(request, 'updated_data')
@@ -624,9 +652,6 @@ def _create_from_ownership_transfer(cls, request):
624652
object_id=transfer['asset__id'],
625653
action=AuditAction.TRANSFER,
626654
user=request.user,
627-
app_label=Asset._meta.app_label,
628-
model_name=Asset._meta.model_name,
629-
log_type=AuditType.PROJECT_HISTORY,
630655
user_uid=request.user.extra_details.uid,
631656
metadata={
632657
'asset_uid': transfer['asset__uid'],
@@ -676,9 +701,6 @@ def _create_from_permissions_request(cls, request):
676701
log_base = {
677702
'user': request.user,
678703
'object_id': asset_id,
679-
'app_label': Asset._meta.app_label,
680-
'model_name': Asset._meta.model_name,
681-
'log_type': AuditType.PROJECT_HISTORY,
682704
'user_uid': request.user.extra_details.uid,
683705
}
684706
# get all users whose permissions changed

kobo/apps/audit_log/signals.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from celery.signals import task_success
44
from django.contrib.auth.signals import user_logged_in
5+
from django.db.models.signals import post_save
56
from django.dispatch import receiver
67
from django_userforeignkey.request import get_current_request
78

@@ -15,7 +16,9 @@
1516
post_remove_partial_perms,
1617
post_remove_perm,
1718
)
19+
from ..openrosa.apps.logger.models import Instance
1820
from .models import AccessLog, ProjectHistoryLog
21+
from .utils import SubmissionUpdate
1922

2023
# Access Log receivers
2124

@@ -69,6 +72,26 @@ def add_assigned_partial_perms(sender, instance, user, perms, **kwargs):
6972
request.partial_permissions_added[user.username] = perms_as_list_of_dicts
7073

7174

75+
@receiver(post_save, sender=Instance)
76+
def add_instance_to_request(instance, created, **kwargs):
77+
request = get_current_request()
78+
if request is None:
79+
return
80+
if getattr(request, 'instances', None) is None:
81+
request.instances = {}
82+
username = instance.user.username if instance.user else None
83+
request.instances.update(
84+
{
85+
instance.id: SubmissionUpdate(
86+
username=username,
87+
status=instance.get_validation_status().get('label', 'None'),
88+
action='add' if created else 'modify',
89+
id=instance.id,
90+
)
91+
}
92+
)
93+
94+
7295
@receiver(post_remove_perm, sender=Asset)
7396
def add_removed_perms(sender, instance, user, codename, **kwargs):
7497
request = _initialize_request()

0 commit comments

Comments
 (0)