Skip to content

Commit

Permalink
feat(projectHistoryLogs): record logs for QA data changes TASK-1506 (#…
Browse files Browse the repository at this point in the history
…5489)

### 📣 Summary
Record project history logs when users update the translations,
transcripts, or QA answers of a submission.


### 💭 Notes
Adding root_uuids to the submission metadata for all submission-based
actions will be done in a later project.



### 👀 Preview steps
<!-- Delete this section if behavior can't change. -->
<!-- If behavior changes or merely may change, add a preview of a
minimal happy path. -->

Bug template:
1. ℹ️ have an account and a project that includes at least one audio
question.
2. Add a submission to the project that includes an audio upload.
3. In Django Admin, add at least one language
4. Go to project > Data and open the the audio response for analysis
5. Add a transcript
6. Go to `api/v2/asset/<asset_uid>/history`
7. 🟢 There should be a new PH log with `action=modify-qa-data` and
`submission: { submitted_by: <username> }` in the metadata, along with
the usual
8. Add a translation
9. 🟢 Verify a new PH log has been added (same action and metadata)
10. Add a QA question and answer
11. 🟢 Verify a new PH log has been added (same action and
metadata). Note there will also be new one with the `update-qa` action
  • Loading branch information
rgraber authored Feb 12, 2025
1 parent a992dd6 commit 1dfcfb2
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 2 deletions.
1 change: 1 addition & 0 deletions kobo/apps/audit_log/audit_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class AuditAction(models.TextChoices):
MODIFY_SERVICE = 'modify-service'
MODIFY_SHARING = 'modify-sharing'
MODIFY_SUBMISSION = 'modify-submission'
MODIFY_QA_DATA = 'modify-qa-data'
MODIFY_USER_PERMISSIONS = 'modify-user-permissions'
PUT_BACK = 'put-back'
REDEPLOY = 'redeploy'
Expand Down
11 changes: 11 additions & 0 deletions kobo/apps/audit_log/base_views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from rest_framework import mixins, viewsets
from rest_framework.views import APIView

from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin

Expand Down Expand Up @@ -104,6 +105,16 @@ def get_object_override(self):
return super().get_object()


class AuditLoggedApiView(APIView):
# requires a separate class to deal with a diamond inheritance problem
# (APIView inherits from GenericViewSet)
def initialize_request(self, request, *args, **kwargs):
request = super().initialize_request(request, *args, **kwargs)
request._request.log_type = self.log_type
request._request._data = request.data.copy()
return request


class AuditLoggedModelViewSet(
AuditLoggedViewSet,
mixins.CreateModelMixin,
Expand Down
68 changes: 68 additions & 0 deletions kobo/apps/audit_log/migrations/0016_add_modify_qa_data_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Generated by Django 4.2.15 on 2025-02-05 16:08

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('audit_log', '0015_add_submission_audit_actions'),
]

operations = [
migrations.AlterField(
model_name='auditlog',
name='action',
field=models.CharField(
choices=[
('add-media', 'Add Media'),
('add-submission', 'Add Submission'),
('allow-anonymous-submissions', 'Allow Anonymous Submissions'),
('archive', 'Archive'),
('auth', 'Auth'),
('clone-permissions', 'Clone Permissions'),
('connect-project', 'Connect Project'),
('create', 'Create'),
('delete', 'Delete'),
('delete-media', 'Delete Media'),
('delete-service', 'Delete Service'),
('delete-submission', 'Delete Submission'),
('deploy', 'Deploy'),
('disable-sharing', 'Disable Sharing'),
(
'disallow-anonymous-submissions',
'Disallow Anonymous Submissions',
),
('disconnect-project', 'Disconnect Project'),
('enable-sharing', 'Enable Sharing'),
('export', 'Export'),
('in-trash', 'In Trash'),
('modify-imported-fields', 'Modify Imported Fields'),
('modify-service', 'Modify Service'),
('modify-sharing', 'Modify Sharing'),
('modify-submission', 'Modify Submission'),
('modify-qa-data', 'Modify Qa Data'),
('modify-user-permissions', 'Modify User Permissions'),
('put-back', 'Put Back'),
('redeploy', 'Redeploy'),
('register-service', 'Register Service'),
('remove', 'Remove'),
('replace-form', 'Replace Form'),
('share-form-publicly', 'Share Form Publicly'),
('share-data-publicly', 'Share Data Publicly'),
('unarchive', 'Unarchive'),
('unshare-form-publicly', 'Unshare Form Publicly'),
('unshare-data-publicly', 'Unshare Data Publicly'),
('update', 'Update'),
('update-content', 'Update Content'),
('update-name', 'Update Name'),
('update-settings', 'Update Settings'),
('update-qa', 'Update Qa'),
('transfer', 'Transfer'),
],
db_index=True,
default='delete',
max_length=30,
),
),
]
25 changes: 25 additions & 0 deletions kobo/apps/audit_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from kobo.apps.audit_log.utils import SubmissionUpdate
from kobo.apps.kobo_auth.shortcuts import User
from kobo.apps.openrosa.apps.logger.models import Instance
from kobo.apps.openrosa.libs.utils.viewer_tools import (
get_client_ip,
get_human_readable_client_user_agent,
Expand Down Expand Up @@ -400,6 +401,7 @@ def create_from_request(cls, request: WSGIRequest):
'submissions': cls._create_from_submission_request,
'submissions-list': cls._create_from_submission_request,
'submission-detail': cls._create_from_submission_request,
'advanced-submission-post': cls._create_from_submission_extra_request,
}
url_name = request.resolver_match.url_name
method = url_name_to_action.get(url_name, None)
Expand Down Expand Up @@ -643,6 +645,29 @@ def _create_from_submission_request(cls, request):
)
ProjectHistoryLog.objects.bulk_create(logs)

@classmethod
def _create_from_submission_extra_request(cls, request):
s_uuid = request._data['submission']
# have to fetch the instance here because we don't have access to it
# anywhere else in the request
instance = Instance.objects.get(uuid=s_uuid)
username = (
instance.user.username if instance.user is not None else 'AnonymousUser'
)
ProjectHistoryLog.objects.create(
user=request.user,
object_id=request.asset.id,
# transcriptions, translations, and QA answers all count as "qa data"
action=AuditAction.MODIFY_QA_DATA,
metadata={
'asset_uid': request.asset.uid,
'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE,
'ip_address': get_client_ip(request),
'source': get_human_readable_client_user_agent(request),
'submission': {'submitted_by': username},
},
)

@classmethod
def _create_from_ownership_transfer(cls, request):
updated_data = getattr(request, 'updated_data')
Expand Down
35 changes: 35 additions & 0 deletions kobo/apps/audit_log/tests/test_project_history_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1795,3 +1795,38 @@ def test_delete_multiple_submissions(self, simulate_error):
).first()
self._check_common_metadata(log2.metadata, PROJECT_HISTORY_LOG_PROJECT_SUBTYPE)
self.assertEqual(log3.action, AuditAction.DELETE_SUBMISSION)

@data(
# Submit as anonymous?, expected username
(True, 'AnonymousUser'),
(False, 'adminuser'),
)
@unpack
def test_update_qa_data(self, is_anonymous, expected_username):
self._add_submission('adminuser' if not is_anonymous else None)
submissions_json = self.asset.deployment.get_submissions(
self.asset.owner, fields=['_uuid']
)
submission_uuid = submissions_json[0]['_uuid']
log_metadata = self._base_project_history_log_test(
method=self.client.post,
url=reverse(
'advanced-submission-post',
kwargs={'asset_uid': self.asset.uid},
),
request_data={
'submission': submission_uuid,
'q1': {
'qual': [
{
'type': 'qual_text',
'uuid': '12345',
'val': 'someval',
}
]
},
},
expected_action=AuditAction.MODIFY_QA_DATA,
expected_subtype=PROJECT_HISTORY_LOG_PROJECT_SUBTYPE,
)
self.assertEqual(log_metadata['submission']['submitted_by'], expected_username)
5 changes: 5 additions & 0 deletions kobo/apps/audit_log/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ def generate_ph_view_set_logstring(description, path, example_path, all):
> enable-sharing
> export
> modify-imported-fields
> modify-qa-data
> modify-service
> modify-sharing
> modify-submission
Expand Down Expand Up @@ -535,6 +536,10 @@ def generate_ph_view_set_logstring(description, path, example_path, all):
b. metadata__paired-data__source_name
* modify-qa-data
a. metadata__submission__submitted_by
* modify-service
a. metadata__hook__uid
Expand Down
10 changes: 8 additions & 2 deletions kobo/apps/subsequences/api_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import ValidationError as APIValidationError
from rest_framework.response import Response
from rest_framework.views import APIView


from kobo.apps.openrosa.apps.logger.models import Instance

from kobo.apps.audit_log.base_views import AuditLoggedApiView
from kobo.apps.audit_log.models import AuditType

from kobo.apps.subsequences.models import SubmissionExtras
from kobo.apps.subsequences.utils.deprecation import get_sanitized_dict_keys
from kpi.models import Asset
Expand Down Expand Up @@ -61,15 +65,17 @@ class AdvancedSubmissionPermission(SubmissionPermission):
perms_map['POST'] = ['%(app_label)s.change_%(model_name)s']


class AdvancedSubmissionView(APIView):
class AdvancedSubmissionView(AuditLoggedApiView):
permission_classes = [AdvancedSubmissionPermission]
queryset = Asset.objects.all()
asset = None
log_type = AuditType.PROJECT_HISTORY

def initial(self, request, asset_uid, *args, **kwargs):
# This must be done first in order to work with SubmissionPermission
# which typically expects to be a nested view under Asset
self.asset = self.get_object(asset_uid)
request._request.asset = self.asset
return super().initial(request, asset_uid, *args, **kwargs)

def get_object(self, uid):
Expand Down

0 comments on commit 1dfcfb2

Please sign in to comment.