diff --git a/kobo/apps/audit_log/migrations/0016_enable_null_user_uid.py b/kobo/apps/audit_log/migrations/0016_enable_null_user_uid.py new file mode 100644 index 0000000000..af85b21d6e --- /dev/null +++ b/kobo/apps/audit_log/migrations/0016_enable_null_user_uid.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2025-01-10 19:32 + +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='user_uid', + field=models.CharField(db_index=True, max_length=22, null=True), + ), + ] diff --git a/kobo/apps/audit_log/models.py b/kobo/apps/audit_log/models.py index 8b5ac16c97..2333322997 100644 --- a/kobo/apps/audit_log/models.py +++ b/kobo/apps/audit_log/models.py @@ -38,6 +38,7 @@ from kpi.fields.kpi_uid import UUID_LENGTH from kpi.models import Asset, ImportTask from kpi.utils.log import logging +from kpi.utils.object_permission import get_database_user ANONYMOUS_USER_PERMISSION_ACTIONS = { # key: (permission, granting?), value: ph log action @@ -396,6 +397,8 @@ def create_from_request(cls, request: WSGIRequest): 'submission-validation-statuses': cls._create_from_submission_request, 'submission-validation-status': cls._create_from_submission_request, 'assetsnapshot-submission-alias': cls._create_from_submission_request, + 'submissions': cls._create_from_submission_request, + 'submissions-list': cls._create_from_submission_request, } url_name = request.resolver_match.url_name method = url_name_to_action.get(url_name, None) @@ -608,7 +611,7 @@ def _create_from_submission_request(cls, request): instances: dict[int:SubmissionUpdate] = getattr(request, 'instances', {}) logs = [] url_name = request.resolver_match.url_name - + user = get_database_user(request.user) for instance in instances.values(): if instance.action == 'add': action = AuditAction.ADD_SUBMISSION @@ -628,10 +631,10 @@ def _create_from_submission_request(cls, request): logs.append( ProjectHistoryLog( - user=request.user, + user=user, object_id=request.asset.id, action=action, - user_uid=request.user.extra_details.uid, + user_uid=user.extra_details.uid, metadata=metadata, ) ) diff --git a/kobo/apps/audit_log/serializers.py b/kobo/apps/audit_log/serializers.py index 5307018584..5601c38609 100644 --- a/kobo/apps/audit_log/serializers.py +++ b/kobo/apps/audit_log/serializers.py @@ -66,14 +66,7 @@ def get_date_created(self, audit_log): return audit_log['date_created'].strftime('%Y-%m-%dT%H:%M:%SZ') -class ProjectHistoryLogSerializer(serializers.ModelSerializer): - user = serializers.HyperlinkedRelatedField( - queryset=get_user_model().objects.all(), - lookup_field='username', - view_name='user-kpi-detail', - ) - date_created = serializers.SerializerMethodField() - username = serializers.SerializerMethodField() +class ProjectHistoryLogSerializer(AuditLogSerializer): class Meta: model = ProjectHistoryLog @@ -94,9 +87,3 @@ class Meta: 'metadata', 'date_created', ) - - def get_date_created(self, audit_log): - return audit_log.date_created.strftime('%Y-%m-%dT%H:%M:%SZ') - - def get_username(self, audit_log): - return audit_log.user.username diff --git a/kobo/apps/audit_log/signals.py b/kobo/apps/audit_log/signals.py index f76e4dbeb7..17d5facc6c 100644 --- a/kobo/apps/audit_log/signals.py +++ b/kobo/apps/audit_log/signals.py @@ -77,8 +77,13 @@ def add_instance_to_request(instance, created, **kwargs): request = get_current_request() if request is None: return + if getattr(instance.asset.asset, 'id', None) is None: + # if an XForm doesn't have a real associated Asset, ignore it + return if getattr(request, 'instances', None) is None: request.instances = {} + if getattr(request, 'asset', None) is None: + request.asset = instance.asset.asset username = instance.user.username if instance.user else None request.instances.update( { 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 34a88056ee..b9dd0501f7 100644 --- a/kobo/apps/audit_log/tests/test_project_history_logs.py +++ b/kobo/apps/audit_log/tests/test_project_history_logs.py @@ -3,11 +3,13 @@ import json import uuid from unittest.mock import patch +from xml.etree import ElementTree as ET import jsonschema.exceptions import responses from ddt import data, ddt, unpack from django.conf import settings +from django.contrib.auth.models import AnonymousUser from django.core.files.uploadedfile import SimpleUploadedFile from django.test import override_settings from django.urls import reverse @@ -19,6 +21,7 @@ from kobo.apps.audit_log.tests.test_models import BaseAuditLogTestCase from kobo.apps.hook.models import Hook from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.openrosa.libs.utils.logger_tools import dict2xform from kpi.constants import ( ASSET_TYPE_TEMPLATE, CLONE_ARG_NAME, @@ -1660,3 +1663,53 @@ def test_multiple_submision_validation_statuses(self): self._check_common_metadata(log2.metadata, PROJECT_HISTORY_LOG_PROJECT_SUBTYPE) self.assertEqual(log2.action, AuditAction.MODIFY_SUBMISSION) self.assertEqual(log2.metadata['submission']['status'], 'On Hold') + + @data( + (True, False), + (False, True), + (False, False), + ) + @unpack + def test_add_submission(self, anonymous, v1): + uuid_ = uuid.uuid4() + self.asset.deploy(backend='mock') + submission_data = { + 'q1': 'answer', + 'q2': 'answer', + 'meta/instanceID': f'uuid:{uuid_}', + 'formhub/uuid': self.asset.deployment.xform.uuid, + '_uuid': str(uuid_), + } + xml = ET.fromstring( + dict2xform(submission_data, self.asset.deployment.xform.id_string) + ) + xml.tag = self.asset.uid + xml.attrib = { + 'id': self.asset.uid, + 'version': self.asset.latest_version.uid, + } + endpoint = 'submissions-list' if v1 else 'submissions' + kwargs = {'username': self.user.username} if not v1 else {} + url = reverse( + self._get_endpoint(endpoint), + kwargs=kwargs, + ) + data = {'xml_submission_file': SimpleUploadedFile('name.txt', ET.tostring(xml))} + self.asset.assign_perm(perm=PERM_ADD_SUBMISSIONS, user_obj=AnonymousUser()) + if not anonymous: + self.client.force_authenticate(user=self.user) + + self.client.post( + url, + data=data, + ) + # make sure a log was created + logs = ProjectHistoryLog.objects.filter(metadata__asset_uid=self.asset.uid) + self.assertEqual(logs.count(), 1) + log = logs.first() + # check the log has the expected fields and metadata + self.assertEqual(log.object_id, self.asset.id) + self.assertEqual(log.action, AuditAction.ADD_SUBMISSION) + self._check_common_metadata(log.metadata, PROJECT_HISTORY_LOG_PROJECT_SUBTYPE) + username = 'AnonymousUser' if anonymous else self.user.username + self.assertEqual(log.metadata['submission']['submitted_by'], username) diff --git a/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py b/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py index 17ae389a95..9d54372086 100644 --- a/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py +++ b/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py @@ -8,6 +8,8 @@ from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer from rest_framework.response import Response +from kobo.apps.audit_log.base_views import AuditLoggedViewSet +from kobo.apps.audit_log.models import AuditType from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.openrosa.apps.logger.models import Instance from kobo.apps.openrosa.libs import filters @@ -27,7 +29,6 @@ TokenAuthentication, ) from kpi.utils.object_permission import get_database_user - from ..utils.rest_framework.viewsets import OpenRosaGenericViewSet from ..utils.xml import extract_confirmation_message @@ -64,7 +65,10 @@ def create_instance_from_json(username, request): class XFormSubmissionApi( - OpenRosaHeadersMixin, mixins.CreateModelMixin, OpenRosaGenericViewSet + OpenRosaHeadersMixin, + mixins.CreateModelMixin, + OpenRosaGenericViewSet, + AuditLoggedViewSet, ): """ Implements OpenRosa Api [FormSubmissionAPI](\ @@ -124,6 +128,7 @@ class XFormSubmissionApi( BrowsableAPIRenderer) serializer_class = SubmissionSerializer template_name = 'submission.xml' + log_type = AuditType.PROJECT_HISTORY def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -150,7 +155,6 @@ def __init__(self, *args, **kwargs): ] def create(self, request, *args, **kwargs): - username = self.kwargs.get('username') if self.request.user.is_anonymous: