Skip to content

Commit 16f52c0

Browse files
authored
fix(submission): retain username in _submitted_by after submitting user has been deleted DEV-1211 (#6430)
### 📣 Summary Ensure that submissions remain attributed to their original submitter even if the user account is deleted, and that the `_submitted_by` value continues to persist correctly during edits and bulk updates. ### 📖 Description Previously, deleting a user caused all their submissions to lose the `_submitted_by` reference or even be deleted, depending on ownership. This PR ensures that: - Submissions made by a deleted user on non-owned projects are retained. - The `_submitted_by` field correctly persists the username of the deleted user. - Editing or bulk-updating those submissions does not remove or overwrite the existing `_submitted_by` value. - Bulk and single validation status updates correctly reference the original `_submitted_by` username.
1 parent 6a11318 commit 16f52c0

File tree

10 files changed

+313
-20
lines changed

10 files changed

+313
-20
lines changed

kobo/apps/audit_log/signals.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.dispatch import receiver
77
from django_userforeignkey.request import get_current_request
88

9+
from kobo.apps.openrosa.libs.utils.common_tags import SUBMITTED_BY
910
from kpi.constants import ASSET_TYPE_SURVEY, PERM_PARTIAL_SUBMISSIONS
1011
from kpi.models import Asset, ImportTask
1112
from kpi.tasks import import_in_background
@@ -76,14 +77,14 @@ def add_instance_to_request(instance, action):
7677
request = get_current_request()
7778
if request is None:
7879
return
79-
if getattr(instance.asset.asset, 'id', None) is None:
80+
if getattr(instance.xform.asset, 'id', None) is None:
8081
# if an XForm doesn't have a real associated Asset, ignore it
8182
return
8283
if getattr(request, 'instances', None) is None:
8384
request.instances = {}
8485
if getattr(request, 'asset', None) is None:
85-
request.asset = instance.asset.asset
86-
username = instance.user.username if instance.user else None
86+
request.asset = instance.xform.asset
87+
username = instance.json.get(SUBMITTED_BY)
8788
request.instances.update(
8889
{
8990
instance.id: SubmissionUpdate(

kobo/apps/kobo_auth/signals.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from django.conf import settings
2-
from django.db.models.signals import post_save
2+
from django.db.models.signals import post_save, pre_delete
33
from django.dispatch import receiver
44
from rest_framework.authtoken.models import Token
55

66
from kobo.apps.kobo_auth.shortcuts import User
77
from kobo.apps.openrosa.apps.main.models.user_profile import UserProfile
8+
from kobo.apps.openrosa.apps.viewer.models import ParsedInstance
89
from kpi.deployment_backends.kc_access.utils import (
910
grant_kc_model_level_perms,
1011
kc_transaction_atomic,
@@ -50,6 +51,16 @@ def default_permissions_post_save(sender, instance, created, raw, **kwargs):
5051
grant_default_model_level_perms(instance)
5152

5253

54+
@receiver(pre_delete, sender=User)
55+
def persist_submitted_by(sender, instance, **kwargs):
56+
if kwargs.get('using') == settings.OPENROSA_DB_ALIAS:
57+
# Give `instance` a better name to avoid confusion
58+
deleted_user = instance
59+
ParsedInstance.objects.filter(
60+
instance__user_id=deleted_user.pk, submitted_by__isnull=True
61+
).update(submitted_by=deleted_user.username)
62+
63+
5364
@receiver(post_save, sender=User)
5465
def save_kobocat_user(sender, instance, created, raw, **kwargs):
5566
"""

kobo/apps/openrosa/apps/logger/models/instance.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ def check_active(self, force):
147147
if not self.xform.user.is_active:
148148
raise AccountInactiveError()
149149

150+
def _get_submitted_by(self):
151+
try:
152+
parsed_instance = self.parsed_instance
153+
except Instance.parsed_instance.RelatedObjectDoesNotExist:
154+
pass
155+
else:
156+
parsed_instance.set_submitted_by(save=True)
157+
return parsed_instance.submitted_by
158+
159+
return self.user.username if self.user is not None else None
160+
150161
def _set_geom(self):
151162
xform = self.xform
152163
data_dictionary = xform.data_dictionary()
@@ -180,7 +191,7 @@ def _set_json(self):
180191

181192
doc[SUBMISSION_TIME] = self.date_created.strftime(MONGO_STRFTIME)
182193
doc[XFORM_ID_STRING] = self._parser.get_xform_id_string()
183-
doc[SUBMITTED_BY] = self.user.username if self.user is not None else None
194+
doc[SUBMITTED_BY] = self._get_submitted_by()
184195
self.json = doc
185196

186197
def _set_parser(self):
@@ -189,8 +200,9 @@ def _set_parser(self):
189200
self.xml, self.xform.data_dictionary())
190201

191202
def _set_survey_type(self):
192-
self.survey_type, created = \
193-
SurveyType.objects.get_or_create(slug=self.get_root_node_name())
203+
self.survey_type, created = SurveyType.objects.get_or_create(
204+
slug=self.get_root_node_name()
205+
)
194206

195207
def _set_uuid(self):
196208
if self.xml and not self.uuid:
@@ -279,8 +291,11 @@ def get_dict(self, force_new=False, flat=True):
279291
"""Return a python object representation of this instance's XML."""
280292
self._set_parser()
281293

282-
return self._parser.get_flat_dict_with_attributes() if flat else\
283-
self._parser.to_dict()
294+
return (
295+
self._parser.get_flat_dict_with_attributes()
296+
if flat
297+
else self._parser.to_dict()
298+
)
284299

285300
def get_full_dict(self):
286301
# TODO should we store all of these in the JSON no matter what?

kobo/apps/openrosa/apps/logger/tests/test_simple_submission.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,21 @@ def _submit_at_hour(self, hour):
5454
except DuplicateInstanceError:
5555
pass
5656

57-
def _submit_simple_yes(self):
58-
create_instance(self.user.username, TempFileProxy(
59-
f'<?xml version=\'1.0\' ?><yes_or_no id="yes_or_no"><yesno>Yes<'
60-
f'/yesno>'
61-
f'<meta><instanceID>uuid:{str(uuid.uuid4())}</instanceID></meta>'
62-
f'</yes_or_no>'), [])
57+
def _submit_simple_yes(self, request=None):
58+
return create_instance(
59+
username=self.user.username,
60+
xml_file=TempFileProxy(
61+
f'<?xml version=\'1.0\' ?>'
62+
f'<yes_or_no id="yes_or_no">'
63+
f' <yesno>Yes</yesno>'
64+
f' <meta>'
65+
f' <instanceID>uuid:{str(uuid.uuid4())}</instanceID>'
66+
f' </meta>'
67+
f'</yes_or_no>'
68+
),
69+
media_files=[],
70+
request=request,
71+
)
6372

6473
def setUp(self):
6574
self.user = User.objects.create(username='admin', email='[email protected]')
@@ -150,3 +159,16 @@ def test_check_exceeded_limit_on_submission(self):
150159
self._submit_simple_yes()
151160
patched.assert_any_call(self.user, UsageType.SUBMISSION)
152161
patched.assert_any_call(self.user, UsageType.STORAGE_BYTES)
162+
163+
def test_parsed_instance_submitted_by_value(self):
164+
class MockRequest:
165+
pass
166+
167+
request = MockRequest()
168+
request.user = self.user
169+
instance = self._submit_simple_yes(request=request)
170+
assert instance.parsed_instance.submitted_by == self.user.username
171+
172+
instance.delete()
173+
instance = self._submit_simple_yes(request=None)
174+
assert instance.parsed_instance.submitted_by is None

kobo/apps/openrosa/apps/logger/utils/instance.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,13 @@ def set_instance_validation_statuses(
133133
if get_current_request() is not None:
134134
get_current_request().instances = {
135135
record['id']: SubmissionUpdate(
136-
username=record['user__username'],
136+
username=record['json'].get('_submitted_by'),
137137
action='modify',
138138
status=validation_status,
139139
id=record['id'],
140140
root_uuid=record['root_uuid'],
141141
)
142-
for record in records_queryset.values('user__username', 'id', 'root_uuid')
142+
for record in records_queryset.values('id', 'root_uuid', 'json')
143143
}
144144
updated_records_count = records_queryset.update(
145145
validation_status=new_validation_status
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.24 on 2025-10-29 06:41
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('viewer', '0005_alter_instancemodification_date_created_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='parsedinstance',
15+
name='submitted_by',
16+
field=models.CharField(blank=True, max_length=255, null=True),
17+
),
18+
]

kobo/apps/openrosa/apps/viewer/models/parsed_instance.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class ParsedInstance(models.Model):
8181
# TODO: decide if decimal field is better than float field.
8282
lat = models.FloatField(null=True)
8383
lng = models.FloatField(null=True)
84+
submitted_by = models.CharField(max_length=255, null=True, blank=True)
8485

8586
@property
8687
def mongo_dict_override(self):
@@ -213,6 +214,14 @@ def query_mongo_no_paging(cls, query, fields, count=False):
213214

214215
return cls._get_mongo_cursor(query, fields)
215216

217+
def set_submitted_by(self, save=False):
218+
if not self.submitted_by and self.instance and self.instance.user:
219+
self.submitted_by = self.instance.user.username
220+
if save:
221+
self.__class__.objects.filter(pk=self.pk).update(
222+
submitted_by=self.submitted_by
223+
)
224+
216225
@classmethod
217226
def _get_mongo_cursor(cls, query, fields):
218227
"""
@@ -305,8 +314,7 @@ def to_dict_for_mongo(self):
305314
TAGS: list(self.instance.tags.names()),
306315
NOTES: self.get_notes(),
307316
VALIDATION_STATUS: self.instance.get_validation_status(),
308-
SUBMITTED_BY: self.instance.user.username
309-
if self.instance.user else None
317+
SUBMITTED_BY: self.submitted_by
310318
}
311319

312320
xform = self.instance.xform
@@ -325,6 +333,7 @@ def to_dict_for_mongo(self):
325333
return MongoHelper.to_safe_dict(d)
326334

327335
def update_mongo(self, asynchronous=True):
336+
self.set_submitted_by(save=True)
328337
d = self.to_dict_for_mongo()
329338
if d.get('_xform_id_string') is None:
330339
# if _xform_id_string, Instance could not be parsed.
@@ -406,6 +415,8 @@ def save(self, asynchronous=False, *args, **kwargs):
406415
self.start_time = None
407416
self.end_time = None
408417
self._set_geopoint()
418+
if created:
419+
self.set_submitted_by()
409420
super().save(*args, **kwargs)
410421

411422
# insert into Mongo.

kobo/apps/trash_bin/utils/account.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ..models.account import AccountTrash
2121
from ..models.project import ProjectTrash
2222
from ..utils.project import delete_asset
23+
from ...openrosa.apps.viewer.models import ParsedInstance
2324

2425

2526
def delete_account(account_trash: AccountTrash):
@@ -98,7 +99,6 @@ def delete_account(account_trash: AccountTrash):
9899
user.delete()
99100

100101
AuditLog.objects.create(**audit_log_params)
101-
102102
delete_kc_user(user.username)
103103

104104
if user.username:

kpi/tests/api/v1/test_api_submissions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,14 @@ def test_edit_submission_twice(self):
198198
def test_get_multiple_edit_links_and_attempt_submit_edits(self):
199199
pass
200200

201+
@pytest.mark.skip(reason='Only usable in v2')
202+
def test_submitted_by_persist_after_edit(self):
203+
pass
204+
205+
@pytest.mark.skip(reason='Only usable in v2')
206+
def test_submitted_by_persists_for_shared_submitter(self):
207+
pass
208+
201209

202210
class SubmissionValidationStatusApiTests(
203211
test_api_submissions.SubmissionValidationStatusApiTests

0 commit comments

Comments
 (0)