Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix!: reject duplicate submissions #5047

Merged
merged 41 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6a177da
862 Avoid duplicate submissions to be saved
rajpatel24 Aug 5, 2024
c9aee65
Add root_uuid field to instance model with unique constraint
rajpatel24 Aug 15, 2024
98a7236
Merge branch 'beta' of github.com:kobotoolbox/kpi into 862-reject_dup…
rajpatel24 Aug 15, 2024
71de437
Merge branch 'beta' of github.com:kobotoolbox/kpi into 862-reject_dup…
rajpatel24 Aug 19, 2024
c9ed0f1
Merge branch 'beta-refactored' into 862-reject_duplicate_submissions
noliveleger Aug 22, 2024
7630294
Merge branch 'beta-refactored' of github.com:kobotoolbox/kpi into 862…
rajpatel24 Aug 28, 2024
f3c89f6
Reject duplicate submissions and improve UUID extraction
rajpatel24 Aug 28, 2024
74dab52
Merge branch 'beta-refactored' into 862-reject_duplicate_submissions
noliveleger Sep 3, 2024
37ed595
Merge branch 'beta-refactored' into 862-reject_duplicate_submissions
noliveleger Sep 3, 2024
061e431
Enhance test cases and submission flow based on PR feedback
rajpatel24 Sep 4, 2024
1001b2a
Resolve merge conflicts
rajpatel24 Sep 6, 2024
f7b0e1b
Merge branch 'beta-refactored' into 862-reject_duplicate_submissions
noliveleger Sep 12, 2024
a01bf8f
Improve submission flow based on the PR feedback
rajpatel24 Sep 12, 2024
c2fc93e
Merge branch 'beta-refactored' of github.com:kobotoolbox/kpi into 862…
rajpatel24 Sep 13, 2024
53c422b
Fix migration conflict: rename and update migration for root_uuid field
rajpatel24 Sep 13, 2024
aa01a6c
Merge branch 'beta-refactored' into 862-reject_duplicate_submissions
noliveleger Sep 17, 2024
498ae91
Merge branch '862-reject_duplicate_submissions' of github.com:kobotoo…
noliveleger Sep 17, 2024
40b0d03
Fix code linter errors
rajpatel24 Sep 17, 2024
24a51bf
Address PR feedback and improve submission flow
rajpatel24 Sep 18, 2024
33e2c46
Merge branch 'beta-refactored' into 862-reject_duplicate_submissions
noliveleger Sep 18, 2024
81d0b9d
Add test for duplicate submission with identical attachment name but …
rajpatel24 Sep 18, 2024
728d0d1
Merge branch '862-reject_duplicate_submissions' of github.com:kobotoo…
rajpatel24 Sep 18, 2024
cc37b6c
Improve code linting compliance
rajpatel24 Sep 19, 2024
cdc71db
Update logic to handle edit submissions with identical attachment nam…
rajpatel24 Sep 23, 2024
306888b
Refactor the command to clean duplicate submissions and add a unique …
rajpatel24 Sep 27, 2024
b3b9b35
Merge branch 'beta-refactored' into 862-reject_duplicate_submissions
rajpatel24 Oct 2, 2024
219cc37
Merge branch 'main' into 862-reject_duplicate_submissions
noliveleger Oct 7, 2024
c416e42
Fix bad merge
noliveleger Oct 7, 2024
b227f5b
Merge branch 'main' into 862-reject_duplicate_submissions
noliveleger Oct 10, 2024
90b3f85
Merge branch 'main' into 862-reject_duplicate_submissions
noliveleger Oct 15, 2024
b18f4a5
Update comments in tests to make them more obvious
noliveleger Oct 15, 2024
362bfa8
Fix failing test case for duplicate submissions with altered attachments
rajpatel24 Oct 17, 2024
c13db0c
Merge branch 'main' into 862-reject_duplicate_submissions
noliveleger Jan 6, 2025
dde80a5
bad merge
noliveleger Jan 6, 2025
578b2be
Fix failing tests
rajpatel24 Jan 8, 2025
b0a4208
Fix failing tests
rajpatel24 Jan 8, 2025
1d177c5
Merge branch 'main' into 862-reject_duplicate_submissions
noliveleger Jan 9, 2025
464b37b
Merge branch '862-reject_duplicate_submissions' of github.com:kobotoo…
noliveleger Jan 9, 2025
928c52a
Update attachment handling to soft delete older versions
rajpatel24 Jan 10, 2025
da68890
Merge branch '862-reject_duplicate_submissions' of github.com:kobotoo…
noliveleger Jan 13, 2025
333b8b3
Update management command
noliveleger Jan 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
CACHE_URL: redis://localhost:6379/3
ENKETO_REDIS_MAIN_URL: redis://localhost:6379/0
KOBOCAT_MEDIA_ROOT: /tmp/test_media
SKIP_TESTS_WITH_CONCURRENCY: 'True'
strategy:
matrix:
python-version: ['3.8', '3.10']
Expand Down
18 changes: 18 additions & 0 deletions kobo/apps/openrosa/apps/api/tests/fixtures/users.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[
{
"model": "auth.user",
"pk": 2,
"fields": {
"username": "bob",
"password": "pbkdf2_sha256$260000$T1eA0O4Ub6c6FAaCsb0fqU$6vX4qMw1VV9tMXFf1d9pL/5z5/2T1MQYYn7vB3p+I2Y=",
"email": "[email protected]",
"first_name": "bob",
"last_name": "bob",
"is_active": true,
"is_staff": false,
"is_superuser": false,
"last_login": null,
"date_joined": "2015-02-12T19:52:14.406Z"
}
}
]
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
# coding: utf-8
import multiprocessing
import os
import uuid
from collections import defaultdict
from functools import partial

import pytest
import requests
import simplejson as json
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.test.testcases import LiveServerTestCase
from django.urls import reverse
from django_digest.test import DigestAuth
from rest_framework.authtoken.models import Token

from kobo.apps.kobo_auth.shortcuts import User
from kobo.apps.openrosa.apps.main.models import UserProfile
from kobo.apps.openrosa.libs.tests.mixins.request_mixin import RequestMixin
from kobo.apps.openrosa.libs.utils.guardian import assign_perm
from kobo_service_account.utils import get_request_headers
from rest_framework import status
Expand All @@ -15,6 +27,7 @@
TestAbstractViewSet
from kobo.apps.openrosa.apps.api.viewsets.xform_submission_api import XFormSubmissionApi
from kobo.apps.openrosa.apps.logger.models import Attachment
from kobo.apps.openrosa.apps.main import tests as main_tests
from kobo.apps.openrosa.libs.constants import (
CAN_ADD_SUBMISSIONS
)
Expand Down Expand Up @@ -510,3 +523,113 @@ def test_submission_blocking_flag(self):
)
response = self.view(request, username=username)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)


class ConcurrentSubmissionTestCase(RequestMixin, LiveServerTestCase):
rajpatel24 marked this conversation as resolved.
Show resolved Hide resolved
"""
Inherit from LiveServerTestCase to be able to test concurrent requests
to submission endpoint in different transactions (and different processes).
Otherwise, DB is populated only on the first request but still empty on
subsequent ones.
"""
fixtures = ['kobo/apps/openrosa/apps/api/tests/fixtures/users']

def setUp(self):
self.user = User.objects.get(username='bob')
self.token, _ = Token.objects.get_or_create(user=self.user)
new_profile, created = UserProfile.objects.get_or_create(
user=self.user
)

def publish_xls_form(self):
path = os.path.join(
settings.OPENROSA_APP_DIR,
'apps',
'main',
'tests',
'fixtures',
'transportation',
'transportation.xls',
)

xform_list_url = reverse('xform-list')
service_account_meta = self.get_meta_from_headers(
get_request_headers(self.user.username)
)
service_account_meta['HTTP_HOST'] = settings.TEST_HTTP_HOST

with open(path, 'rb') as xls_file:
post_data = {'xls_file': xls_file}
response = self.client.post(xform_list_url, data=post_data, **service_account_meta)

assert response.status_code == status.HTTP_201_CREATED

@pytest.mark.skipif(
settings.SKIP_TESTS_WITH_CONCURRENCY, reason='GitLab does not seem to support multi-processes'
)
def test_post_concurrent_same_submissions(self):
DUPLICATE_SUBMISSIONS_COUNT = 2 # noqa

self.publish_xls_form()
username = 'bob'
survey = 'transport_2011-07-25_19-05-49'
results = defaultdict(int)

with multiprocessing.Pool() as pool:
for result in pool.map(
partial(
submit_data,
live_server_url=self.live_server_url,
survey_=survey,
username_=username,
token_=self.token.key
),
range(DUPLICATE_SUBMISSIONS_COUNT),
):
results[result] += 1

assert results[status.HTTP_201_CREATED] == 1
assert results[status.HTTP_409_CONFLICT] == DUPLICATE_SUBMISSIONS_COUNT - 1
rajpatel24 marked this conversation as resolved.
Show resolved Hide resolved


def submit_data(identifier, survey_, username_, live_server_url, token_):
"""
Submit data to live server.

It has to be outside `ConcurrentSubmissionTestCase` class to be pickled by
`multiprocessing.Pool().map()`.
"""
media_file = '1335783522563.jpg'
main_directory = os.path.dirname(main_tests.__file__)
path = os.path.join(
main_directory,
'fixtures',
'transportation',
'instances',
survey_,
media_file,
)
with open(path, 'rb') as f:
f = InMemoryUploadedFile(
f,
'media_file',
media_file,
'image/jpg',
os.path.getsize(path),
None,
)
submission_path = os.path.join(
main_directory,
'fixtures',
'transportation',
'instances',
survey_,
f'{survey_}.xml',
)
with open(submission_path) as sf:
files = {'xml_submission_file': sf, 'media_file': f}
headers = {'Authorization': f'Token {token_}'}
response = requests.post(
f'{live_server_url}/{username_}/submission', files=files, headers=headers
)
return response.status_code
31 changes: 30 additions & 1 deletion kobo/apps/openrosa/apps/logger/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# coding: utf-8
from django.utils.translation import gettext as t
class ConflictingXMLHashInstanceError(Exception):
pass

rajpatel24 marked this conversation as resolved.
Show resolved Hide resolved

class DuplicateInstanceError(Exception):
def __init__(self, message='Duplicate Instance'):
super().__init__(message)


class DuplicateUUIDError(Exception):
Expand All @@ -10,5 +16,28 @@ class FormInactiveError(Exception):
pass


class InstanceEmptyError(Exception):
def __init__(self, message='Empty instance'):
super().__init__(message)


class InstanceInvalidUserError(Exception):
def __init__(self, message='Could not determine the user'):
super().__init__(message)


class InstanceMultipleNodeError(Exception):
pass


class InstanceParseError(Exception):
def __init__(self, message='The instance could not be parsed'):
super().__init__(message)


class TemporarilyUnavailableError(Exception):
pass


class XLSFormError(Exception):
pass
Loading
Loading