Skip to content

Commit 4fa88e9

Browse files
Require name initials for DUA signing (#2106) (#2558)
## Summary - [x] Users must now type their initials to sign a Data Use Agreement instead of just clicking "I agree". - [x] Initials are validated against the user's profile name (case-insensitive with unicode normalization). - [x] Provides stronger confirmation that users have read and agreed to the terms. ## Approach 1. **Business logic in the model**: The initials validation rule lives in `DUASignature.validate_signature_initials()` as a static method. This keeps the business rule testable and reusable. 2. **Unicode-aware comparison**: Uses `unicodedata.normalize('NFKD', text.casefold())` for proper unicode handling per review feedback. 3. **Simple Form**: `DUASignatureForm` is a plain Django Form that only collects data. It delegates validation to the model's static method. 4. **Explicit view logic**: The view explicitly creates the `DUASignature` object, making it clear what's being persisted. ## Screenshots <img width="1338" height="831" alt="Screenshot 2026-01-16 at 1 42 36 PM" src="https://github.com/user-attachments/assets/989945ae-f228-4e4f-8eb0-e1e3746f88b1" /> Closes #2106
2 parents e33b743 + 2548004 commit 4fa88e9

File tree

6 files changed

+182
-17
lines changed

6 files changed

+182
-17
lines changed

physionet-django/project/forms.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
DataAccessRequest,
3030
DataAccessRequestReviewer,
3131
DUA,
32+
DUASignature,
3233
License,
3334
Metadata,
3435
ProgrammingLanguage,
@@ -1108,6 +1109,32 @@ class Meta:
11081109
}
11091110

11101111

1112+
class DUASignatureForm(forms.Form):
1113+
"""
1114+
Form for signing Data Use Agreement by typing initials.
1115+
"""
1116+
initials = forms.CharField(
1117+
widget=forms.TextInput(attrs={
1118+
'class': 'form-control',
1119+
'placeholder': 'Type your initials',
1120+
}),
1121+
label='Please type your initials to indicate your consent',
1122+
)
1123+
1124+
def __init__(self, user, *args, **kwargs):
1125+
super().__init__(*args, **kwargs)
1126+
self.user = user
1127+
expected_initials = DUASignature.get_user_initials(user)
1128+
self.fields['initials'].help_text = (
1129+
f'Please type your initials exactly as: {expected_initials}'
1130+
)
1131+
1132+
def clean_initials(self):
1133+
initials = self.cleaned_data.get('initials', '').strip()
1134+
DUASignature.validate_signature_initials(self.user, initials)
1135+
return initials
1136+
1137+
11111138
class DataAccessRequestForm(forms.ModelForm):
11121139
class Meta:
11131140
model = DataAccessRequest

physionet-django/project/modelcomponents/access.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import unicodedata
12
from datetime import timedelta
23
from enum import IntEnum
34

45
from django.contrib.auth.hashers import check_password, make_password
56
from django.contrib.contenttypes.fields import GenericForeignKey
67
from django.contrib.contenttypes.models import ContentType
8+
from django.core.exceptions import ValidationError
79
from django.db import models
810
from django.utils import timezone
911
from django.utils.crypto import get_random_string
@@ -42,6 +44,34 @@ class DUASignature(models.Model):
4244
class Meta:
4345
default_permissions = ()
4446

47+
@staticmethod
48+
def get_user_initials(user):
49+
"""Get initials from user's profile name."""
50+
initials = ''
51+
if user.profile.first_names:
52+
for name in user.profile.first_names.split():
53+
if name:
54+
initials += name[0].upper()
55+
if user.profile.last_name:
56+
initials += user.profile.last_name[0].upper()
57+
return initials
58+
59+
@staticmethod
60+
def normalize_for_comparison(text):
61+
"""Normalize text for Unicode-aware case-insensitive comparison."""
62+
return unicodedata.normalize('NFKD', text.casefold())
63+
64+
@staticmethod
65+
def validate_signature_initials(user, initials):
66+
if not initials or not initials.strip():
67+
raise ValidationError('You must enter your initials to sign the agreement.')
68+
expected_initials = DUASignature.get_user_initials(user)
69+
if DUASignature.normalize_for_comparison(initials.strip()) != \
70+
DUASignature.normalize_for_comparison(expected_initials):
71+
raise ValidationError(
72+
f'The initials entered do not match. Please enter: {expected_initials}'
73+
)
74+
4575

4676
class DataAccessRequest(models.Model):
4777
PENDING_VALUE = 0

physionet-django/project/templates/project/sign_dua.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ <h2>{{ project.dua.name }}</h2>
1818
<hr>
1919
<form method="POST">
2020
{% csrf_token %}
21+
<div class="form-group">
22+
<label for="id_initials">{{ form.initials.label }}</label>
23+
{{ form.initials }}
24+
{% if form.initials.help_text %}
25+
<small class="form-text text-muted">{{ form.initials.help_text }}</small>
26+
{% endif %}
27+
{% if form.initials.errors %}
28+
<div class="text-danger">{{ form.initials.errors.0 }}</div>
29+
{% endif %}
30+
</div>
2131
<p class="text-center">
2232
<button class="btn btn-lg btn-success" name="agree" type="submit">I agree</button> <a class="btn btn-lg btn-danger" href="{% url 'published_project' project.slug project.version %}">I do not agree</a>
2333
</p>

physionet-django/project/test_s3.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
)
2424
from project.models import (
2525
AWS,
26+
DUASignature,
2627
PublishedProject,
2728
)
2829
from user.models import (
2930
User,
3031
CloudInformation,
32+
Profile,
3133
Training,
3234
TrainingStatus,
3335
)
@@ -279,9 +281,10 @@ def test_controlled_access_points(self):
279281

280282
for user in add_aws_users + add_nonaws_users:
281283
self.client.force_login(user)
284+
initials = DUASignature.get_user_initials(user)
282285
self.client.post(
283286
reverse('sign_dua', args=(project.slug, project.version)),
284-
data={'agree': ''},
287+
data={'agree': '', 'initials': initials},
285288
)
286289
self.assertTrue(can_view_project_files(project, user))
287290

@@ -379,6 +382,11 @@ def create_example_users(self, project, count, aws_verified, signed_dua):
379382
is_credentialed=True,
380383
credential_datetime=now,
381384
)
385+
Profile.objects.create(
386+
user=user,
387+
first_names=f"Test{n}",
388+
last_name=f"User{n}",
389+
)
382390
users.append(user)
383391
for i, training_type in enumerate(training_types):
384392
Training.objects.create(

physionet-django/project/test_views.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
CoreProject,
2020
DataAccessRequest,
2121
DataAccessRequestReviewer,
22+
DUASignature,
2223
License,
2324
ProjectType,
2425
PublishedAuthor,
@@ -734,7 +735,7 @@ def test_credentialed(self):
734735
# Sign the dua and get file again
735736
response = self.client.post(reverse('sign_dua',
736737
args=(project.slug, project.version,)),
737-
data={'agree':''})
738+
data={'agree': '', 'initials': 'RGM'})
738739
response = self.client.get(reverse(
739740
'serve_published_project_file',
740741
args=(project.slug, project.version, 'SHA256SUMS.txt')))
@@ -951,7 +952,7 @@ def test_serve_file(self):
951952
self.client.login(username='rgmark@mit.edu', password='Tester11!')
952953
response = self.client.post(
953954
reverse('sign_dua', args=(project.slug, project.version,)),
954-
data={'agree': ''})
955+
data={'agree': '', 'initials': 'RGM'})
955956

956957
response = self.client.get(url + 'foo/')
957958
self.assertEqual(response.status_code, 200)
@@ -964,6 +965,90 @@ def test_serve_file(self):
964965
self.assertEqual(response['X-Accel-Redirect'], path + '%C3%80')
965966

966967

968+
class TestDUASignatureValidation(TestMixin):
969+
"""
970+
Test DUA signing with initials validation.
971+
"""
972+
973+
def test_sign_dua_with_correct_initials(self):
974+
"""User can sign DUA when typing their correct initials."""
975+
project = PublishedProject.objects.get(slug='demoeicu', version='2.0.0')
976+
self.client.login(username='rgmark@mit.edu', password='Tester11!')
977+
978+
response = self.client.post(
979+
reverse('sign_dua', args=(project.slug, project.version)),
980+
data={'agree': '', 'initials': 'RGM'}
981+
)
982+
self.assertEqual(response.status_code, 200)
983+
self.assertTrue(DUASignature.objects.filter(
984+
user__email='rgmark@mit.edu', project=project
985+
).exists())
986+
987+
def test_sign_dua_with_wrong_initials(self):
988+
"""User cannot sign DUA when typing wrong initials."""
989+
project = PublishedProject.objects.get(slug='demoeicu', version='2.0.0')
990+
self.client.login(username='rgmark@mit.edu', password='Tester11!')
991+
992+
response = self.client.post(
993+
reverse('sign_dua', args=(project.slug, project.version)),
994+
data={'agree': '', 'initials': 'XYZ'}
995+
)
996+
self.assertEqual(response.status_code, 200)
997+
self.assertFalse(DUASignature.objects.filter(
998+
user__email='rgmark@mit.edu', project=project
999+
).exists())
1000+
self.assertContains(response, 'do not match')
1001+
1002+
def test_sign_dua_with_empty_initials(self):
1003+
"""User cannot sign DUA without entering their initials."""
1004+
project = PublishedProject.objects.get(slug='demoeicu', version='2.0.0')
1005+
self.client.login(username='rgmark@mit.edu', password='Tester11!')
1006+
1007+
response = self.client.post(
1008+
reverse('sign_dua', args=(project.slug, project.version)),
1009+
data={'agree': '', 'initials': ''}
1010+
)
1011+
self.assertEqual(response.status_code, 200)
1012+
self.assertFalse(DUASignature.objects.filter(
1013+
user__email='rgmark@mit.edu', project=project
1014+
).exists())
1015+
self.assertContains(response, 'This field is required')
1016+
1017+
def test_sign_dua_case_insensitive(self):
1018+
"""Initials validation is case-insensitive."""
1019+
project = PublishedProject.objects.get(slug='demoeicu', version='2.0.0')
1020+
self.client.login(username='rgmark@mit.edu', password='Tester11!')
1021+
1022+
response = self.client.post(
1023+
reverse('sign_dua', args=(project.slug, project.version)),
1024+
data={'agree': '', 'initials': 'rgm'}
1025+
)
1026+
self.assertEqual(response.status_code, 200)
1027+
self.assertTrue(DUASignature.objects.filter(
1028+
user__email='rgmark@mit.edu', project=project
1029+
).exists())
1030+
1031+
1032+
class TestDUASignatureNormalization(TestCase):
1033+
"""
1034+
Test Unicode normalization for initials comparison.
1035+
"""
1036+
1037+
def test_normalize_for_comparison(self):
1038+
"""Test that normalize_for_comparison handles Unicode properly."""
1039+
normalize = DUASignature.normalize_for_comparison
1040+
1041+
# Case insensitivity
1042+
self.assertEqual(normalize('ABC'), normalize('abc'))
1043+
1044+
# German sharp S (ß) casefolds to 'ss'
1045+
self.assertEqual(normalize('ß'), normalize('ss'))
1046+
1047+
# Different Unicode representations of same character
1048+
# é as single character vs e + combining acute accent
1049+
self.assertEqual(normalize('é'), normalize('é'))
1050+
1051+
9671052
class TestState(TestMixin):
9681053
"""
9691054
Test that all objects are in their intended states, during and

physionet-django/project/views.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2135,21 +2135,26 @@ def sign_dua(request, project_slug, version):
21352135

21362136
license = project.license
21372137
license_content = project.license_content(fmt='html')
2138-
if request.method == 'POST' and 'agree' in request.POST:
2139-
DUASignature.objects.create(user=user, project=project)
2140-
if has_s3_credentials() and files_sent_to_S3(project):
2141-
if (
2142-
hasattr(user, 'cloud_information')
2143-
and user.cloud_information is not None
2144-
and user.cloud_information.aws_verification_datetime is not None
2145-
):
2146-
add_user_to_access_point_policy(project, user)
2147-
2148-
return render(request, 'project/sign_dua_complete.html', {
2149-
'project':project})
2138+
form = forms.DUASignatureForm(user)
21502139

2151-
return render(request, 'project/sign_dua.html', {'project':project,
2152-
'license':license, 'license_content':license_content})
2140+
if request.method == 'POST' and 'agree' in request.POST:
2141+
form = forms.DUASignatureForm(user, data=request.POST)
2142+
if form.is_valid():
2143+
DUASignature.objects.create(user=user, project=project)
2144+
if has_s3_credentials() and files_sent_to_S3(project):
2145+
if (
2146+
hasattr(user, 'cloud_information')
2147+
and user.cloud_information is not None
2148+
and user.cloud_information.aws_verification_datetime is not None
2149+
):
2150+
add_user_to_access_point_policy(project, user)
2151+
2152+
return render(request, 'project/sign_dua_complete.html', {
2153+
'project': project})
2154+
2155+
return render(request, 'project/sign_dua.html', {
2156+
'project': project, 'license': license,
2157+
'license_content': license_content, 'form': form})
21532158

21542159

21552160
@login_required

0 commit comments

Comments
 (0)