Skip to content

Commit 34bd9bc

Browse files
authored
Implement end of study message (#4271)
Pitch DIAGNijmegen/rse-roadmap#426 Implements the end-of-reader-study message option in the reader study settings.
1 parent 9263003 commit 34bd9bc

5 files changed

Lines changed: 97 additions & 4 deletions

File tree

app/grandchallenge/reader_studies/forms.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ class Meta:
147147
"enable_autosaving",
148148
"allow_show_all_annotations",
149149
"roll_over_answers_for_n_cases",
150+
"end_of_study_text_markdown",
150151
)
151152
help_texts = READER_STUDY_HELP_TEXTS
152153
widgets = {
@@ -252,6 +253,7 @@ class Meta(ReaderStudyCreateForm.Meta):
252253
"allow_show_all_annotations",
253254
"roll_over_answers_for_n_cases",
254255
"case_text",
256+
"end_of_study_text_markdown",
255257
)
256258
widgets = {
257259
"case_text": JSONEditorWidget(schema=CASE_TEXT_SCHEMA),
@@ -263,6 +265,7 @@ class Meta(ReaderStudyCreateForm.Meta):
263265
"organizations": Select2MultipleWidget,
264266
"optional_hanging_protocols": Select2MultipleWidget,
265267
"view_content": JSONEditorWidget(schema=VIEW_CONTENT_SCHEMA),
268+
"end_of_study_text_markdown": MarkdownEditorInlineWidget,
266269
}
267270
help_texts = {
268271
**READER_STUDY_HELP_TEXTS,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.23 on 2025-08-22 16:21
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("reader_studies", "0069_alter_readerstudy_enable_autosaving"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="readerstudy",
15+
name="end_of_study_text_markdown",
16+
field=models.TextField(
17+
blank=True,
18+
help_text="Text to show when a user has completed the reader study",
19+
),
20+
),
21+
]

app/grandchallenge/reader_studies/models.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,10 @@ class ReaderStudy(
351351
"Usernames and avatars will be hidden to protect other readers' privacy."
352352
),
353353
)
354+
end_of_study_text_markdown = models.TextField(
355+
blank=True,
356+
help_text="Text to show when a user has completed the reader study",
357+
)
354358
max_credits = models.PositiveIntegerField(
355359
null=True,
356360
blank=True,
@@ -386,6 +390,7 @@ class Meta(UUIDModel.Meta, TitleSlugDescriptionModel.Meta):
386390
"modalities",
387391
"structures",
388392
"organizations",
393+
"end_of_study_text_markdown",
389394
}
390395

391396
optional_copy_fields = [
@@ -555,7 +560,25 @@ def remove_reader(self, user):
555560
@property
556561
def help_text(self) -> str:
557562
"""The cleaned help text from the markdown sources"""
558-
return md2html(self.help_text_markdown, link_blank_target=True)
563+
# Deprecated method
564+
return self.help_text_safe
565+
566+
@property
567+
def help_text_safe(self) -> str:
568+
"""The cleaned help text from the markdown sources"""
569+
return md2html(
570+
self.help_text_markdown,
571+
link_blank_target=True,
572+
create_permalink_for_headers=False,
573+
)
574+
575+
@property
576+
def end_of_study_text_safe(self) -> str:
577+
return md2html(
578+
self.end_of_study_text_markdown,
579+
link_blank_target=True,
580+
create_permalink_for_headers=False,
581+
)
559582

560583
@cached_property
561584
def study_image_names(self):

app/grandchallenge/reader_studies/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ class Meta:
182182
"logo",
183183
"description",
184184
"help_text",
185+
"help_text_safe",
185186
"pk",
186187
"questions",
187188
"title",
@@ -193,6 +194,7 @@ class Meta:
193194
"allow_case_navigation",
194195
"allow_show_all_annotations",
195196
"roll_over_answers_for_n_cases",
197+
"end_of_study_text_safe",
196198
)
197199

198200

app/tests/reader_studies_tests/test_models.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from datetime import timedelta
33

44
import pytest
5+
from bs4 import BeautifulSoup
56
from django.core.exceptions import ObjectDoesNotExist, ValidationError
67
from django.db.models import ProtectedError
78
from django.db.utils import IntegrityError
@@ -368,18 +369,61 @@ def test_score_for_user(
368369
assert score["score__avg"] == 0.5
369370

370371

372+
@pytest.mark.parametrize(
373+
"markdown_field,rendered_field",
374+
(
375+
("help_text_markdown", "help_text"),
376+
("help_text_markdown", "help_text_safe"),
377+
("end_of_study_text_markdown", "end_of_study_text_safe"),
378+
),
379+
)
371380
@pytest.mark.django_db
372-
def test_help_markdown_is_scrubbed(client):
381+
def test_help_markdown_is_scrubbed(client, markdown_field, rendered_field):
382+
somewhat_naughty_string = (
383+
"<a href='javascript:alert(1)' onmouseover='alert(1)'>Click me</a>"
384+
"<script>alert('XSS')</script>"
385+
)
386+
373387
rs = ReaderStudyFactory(
374-
help_text_markdown="<b>My Help Text</b><script>naughty</script>",
388+
**{
389+
markdown_field: "# Here come some naughty strings\n\n"
390+
+ somewhat_naughty_string
391+
}
375392
)
376393
u = UserFactory()
377394
rs.add_reader(u)
378395

379396
response = get_view_for_user(client=client, url=rs.api_url, user=u)
380397

381398
assert response.status_code == 200
382-
assert response.json()["help_text"] == "<p><b>My Help Text</b>naughty</p>"
399+
400+
rendered_text = response.json()[rendered_field]
401+
parsed_text = BeautifulSoup(rendered_text, "html.parser")
402+
assert parsed_text, "Parsing output failed"
403+
404+
# Check that there are not <script> tags, event handlers, and
405+
# javascript: URLs in the generated output
406+
assert not parsed_text.find_all("script")
407+
for element in parsed_text.find_all():
408+
for attribute in element.attrs:
409+
has_on_attr = attribute.lower().startswith("on")
410+
assert not has_on_attr, "Found event handler"
411+
for element in parsed_text.find_all():
412+
for attr_value in element.attrs.values():
413+
if isinstance(attr_value, str):
414+
attr_values = [attr_value]
415+
elif isinstance(attr_value, (tuple, list)):
416+
attr_values = list(attr_value)
417+
else:
418+
continue
419+
420+
for value in attr_values:
421+
if isinstance(value, str):
422+
assert not value.lower().strip().startswith("javascript:")
423+
424+
# Make sure that there are no headerlinks in the output
425+
for element in parsed_text.find_all():
426+
assert "headerlink" not in element.get("class", [])
383427

384428

385429
@pytest.mark.django_db

0 commit comments

Comments
 (0)