Skip to content

Commit 77c0438

Browse files
Dynamic examples for view content field help text (#3697)
Related to the first part of https://github.com/DIAGNijmegen/rse-roadmap/issues/362 --------- Co-authored-by: Anne Mickan <amickan1990@gmail.com>
1 parent dba7cdb commit 77c0438

File tree

10 files changed

+529
-48
lines changed

10 files changed

+529
-48
lines changed

app/grandchallenge/algorithms/forms.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
)
8484
from grandchallenge.evaluation.utils import get
8585
from grandchallenge.groups.forms import UserGroupForm
86+
from grandchallenge.hanging_protocols.forms import ViewContentExampleMixin
8687
from grandchallenge.hanging_protocols.models import VIEW_CONTENT_SCHEMA
8788
from grandchallenge.reader_studies.models import ReaderStudy
8889
from grandchallenge.subdomains.utils import reverse, reverse_lazy
@@ -241,6 +242,7 @@ class AlgorithmForm(
241242
AlgorithmIOValidationMixin,
242243
WorkstationUserFilterMixin,
243244
SaveFormInitMixin,
245+
ViewContentExampleMixin,
244246
ModelForm,
245247
):
246248
inputs = ModelMultipleChoiceField(
@@ -378,17 +380,6 @@ def __init__(self, *args, **kwargs):
378380
MaxValueValidator(settings.ALGORITHMS_MAX_MEMORY_GB),
379381
]
380382

381-
if self.instance:
382-
interface_slugs = (
383-
(self.instance.inputs.all() | self.instance.outputs.all())
384-
.distinct()
385-
.values_list("slug", flat=True)
386-
)
387-
self.fields["view_content"].help_text += (
388-
" The following interfaces are used in your algorithm: "
389-
f"{oxford_comma(interface_slugs)}."
390-
)
391-
392383

393384
class UserAlgorithmsForPhaseMixin:
394385
def __init__(self, *args, user, phase, **kwargs):

app/grandchallenge/algorithms/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,10 @@ def add_user(self, user):
443443
def remove_user(self, user):
444444
return user.groups.remove(self.users_group)
445445

446+
@cached_property
447+
def interfaces(self):
448+
return (self.inputs.all() | self.outputs.all()).distinct()
449+
446450
@cached_property
447451
def user_statistics(self):
448452
return (

app/grandchallenge/archives/forms.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
MarkdownEditorInlineWidget,
3939
)
4040
from grandchallenge.groups.forms import UserGroupForm
41+
from grandchallenge.hanging_protocols.forms import ViewContentExampleMixin
4142
from grandchallenge.hanging_protocols.models import VIEW_CONTENT_SCHEMA
4243
from grandchallenge.reader_studies.models import ReaderStudy
4344
from grandchallenge.subdomains.utils import reverse_lazy
@@ -46,6 +47,7 @@
4647
class ArchiveForm(
4748
WorkstationUserFilterMixin,
4849
SaveFormInitMixin,
50+
ViewContentExampleMixin,
4951
ModelForm,
5052
):
5153
def __init__(self, *args, **kwargs):
@@ -67,17 +69,6 @@ def __init__(self, *args, **kwargs):
6769
.filter(has_active_image=True)
6870
.distinct()
6971
)
70-
if self.instance:
71-
interface_slugs = (
72-
self.instance.items.exclude(values__isnull=True)
73-
.values_list("values__interface__slug", flat=True)
74-
.order_by()
75-
.distinct()
76-
)
77-
self.fields["view_content"].help_text += (
78-
" The following interfaces are used in your archive: "
79-
f"{', '.join(interface_slugs)}."
80-
)
8172

8273
class Meta:
8374
model = Archive

app/grandchallenge/components/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2578,6 +2578,14 @@ def values_for_interfaces(self):
25782578
}
25792579
return values_for_interfaces
25802580

2581+
@cached_property
2582+
def interfaces(self):
2583+
return ComponentInterface.objects.filter(
2584+
pk__in=self.civ_sets_related_manager.exclude(
2585+
values__isnull=True
2586+
).values_list("values__interface__pk", flat=True)
2587+
).distinct()
2588+
25812589

25822590
class Tarball(UUIDModel):
25832591
ImportStatusChoices = ImportStatusChoices

app/grandchallenge/evaluation/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
Submission,
5757
)
5858
from grandchallenge.evaluation.utils import SubmissionKindChoices
59+
from grandchallenge.hanging_protocols.forms import ViewContentExampleMixin
5960
from grandchallenge.hanging_protocols.models import VIEW_CONTENT_SCHEMA
6061
from grandchallenge.subdomains.utils import reverse, reverse_lazy
6162
from grandchallenge.uploads.models import UserUpload
@@ -150,6 +151,7 @@ class PhaseUpdateForm(
150151
PhaseTitleMixin,
151152
WorkstationUserFilterMixin,
152153
SaveFormInitMixin,
154+
ViewContentExampleMixin,
153155
forms.ModelForm,
154156
):
155157
def __init__(self, *args, **kwargs):

app/grandchallenge/evaluation/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,12 @@ def set_default_interfaces(self):
868868
[ComponentInterface.objects.get(slug="metrics-json-file")]
869869
)
870870

871+
@cached_property
872+
def interfaces(self):
873+
return (
874+
self.algorithm_inputs.all() | self.algorithm_outputs.all()
875+
).distinct()
876+
871877
def assign_permissions(self):
872878
assign_perm("view_phase", self.challenge.admins_group, self)
873879
assign_perm("change_phase", self.challenge.admins_group, self)

app/grandchallenge/hanging_protocols/forms.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
1+
import json
2+
13
from crispy_forms.helper import FormHelper
24
from crispy_forms.layout import ButtonHolder, Div, Layout, Submit
35
from django import forms
6+
from django.core.exceptions import ValidationError
7+
from django.utils.text import format_lazy
48

9+
from grandchallenge.components.models import (
10+
InterfaceKind,
11+
InterfaceKindChoices,
12+
)
513
from grandchallenge.core.forms import SaveFormInitMixin
14+
from grandchallenge.core.templatetags.remove_whitespace import oxford_comma
15+
from grandchallenge.core.validators import JSONValidator
616
from grandchallenge.core.widgets import JSONEditorWidget
717
from grandchallenge.hanging_protocols.models import (
818
HANGING_PROTOCOL_SCHEMA,
19+
VIEW_CONTENT_SCHEMA,
920
HangingProtocol,
21+
ViewportNames,
1022
)
23+
from grandchallenge.subdomains.utils import reverse
1124

1225

1326
class HangingProtocolForm(SaveFormInitMixin, forms.ModelForm):
@@ -116,3 +129,92 @@ def _validate_slice_plane_indicator(self, *, viewport, viewport_names):
116129
error=f"Viewport {viewport['viewport_name']} has a slice_plane_indicator that is the same as the viewport_name.",
117130
field="json",
118131
)
132+
133+
134+
class ViewContentExampleMixin:
135+
def __init__(self, *args, **kwargs):
136+
super().__init__(*args, **kwargs)
137+
if self.instance:
138+
interface_slugs = [
139+
interface.slug for interface in self.instance.interfaces
140+
]
141+
142+
if len(interface_slugs) > 0:
143+
self.fields[
144+
"view_content"
145+
].help_text += f"The following interfaces are used in your {self.instance._meta.verbose_name}: {oxford_comma(interface_slugs)}. "
146+
147+
view_content_example = self.generate_view_content_example()
148+
149+
if view_content_example:
150+
self.fields[
151+
"view_content"
152+
].help_text += f"Example usage: {view_content_example}. "
153+
else:
154+
self.fields[
155+
"view_content"
156+
].help_text += "No interfaces of type image, chart, pdf, mp4, thumbnail_jpg or thumbnail_png are used. At least one interface of those types is needed to configure the viewer. "
157+
158+
self.fields["view_content"].help_text += format_lazy(
159+
'Refer to the <a href="{}">documentation</a> for more information',
160+
reverse("documentation:detail", args=["viewer-content"]),
161+
)
162+
163+
def generate_view_content_example(self):
164+
images = [
165+
interface.slug
166+
for interface in self.instance.interfaces
167+
if interface.kind == InterfaceKindChoices.IMAGE
168+
]
169+
mandatory_isolation_interfaces = [
170+
interface.slug
171+
for interface in self.instance.interfaces
172+
if interface.kind
173+
in InterfaceKind.interface_type_mandatory_isolation()
174+
]
175+
176+
if not images and not mandatory_isolation_interfaces:
177+
return None
178+
179+
overlays = [
180+
interface.slug
181+
for interface in self.instance.interfaces
182+
if interface.kind
183+
not in (
184+
*InterfaceKind.interface_type_undisplayable(),
185+
*InterfaceKind.interface_type_mandatory_isolation(),
186+
InterfaceKindChoices.IMAGE,
187+
)
188+
]
189+
190+
if images:
191+
overlays_per_image = len(overlays) // len(images)
192+
remaining_overlays = len(overlays) % len(images)
193+
194+
view_content_example = {}
195+
196+
for port in ViewportNames.values:
197+
if mandatory_isolation_interfaces:
198+
view_content_example[port] = [
199+
mandatory_isolation_interfaces.pop(0)
200+
]
201+
elif images:
202+
view_content_example[port] = [images.pop(0)]
203+
for _ in range(overlays_per_image):
204+
view_content_example[port].append(overlays.pop(0))
205+
if remaining_overlays > 0:
206+
view_content_example[port].append(overlays.pop(0))
207+
remaining_overlays -= 1
208+
209+
try:
210+
JSONValidator(schema=VIEW_CONTENT_SCHEMA)(
211+
value=view_content_example
212+
)
213+
self.instance.clean_view_content(
214+
view_content=view_content_example,
215+
hanging_protocol=self.instance.hanging_protocol,
216+
)
217+
except ValidationError as error:
218+
raise RuntimeError("view_content example is not valid.") from error
219+
220+
return json.dumps(view_content_example)

app/grandchallenge/hanging_protocols/models.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,9 @@ class HangingProtocolMixin(models.Model):
306306
view_content = models.JSONField(
307307
blank=True,
308308
default=dict,
309-
validators=[JSONValidator(schema=VIEW_CONTENT_SCHEMA)],
309+
validators=[
310+
JSONValidator(schema=VIEW_CONTENT_SCHEMA),
311+
],
310312
)
311313
hanging_protocol = models.ForeignKey(
312314
"hanging_protocols.HangingProtocol",
@@ -326,24 +328,28 @@ class HangingProtocolMixin(models.Model):
326328
def clean(self):
327329
super().clean()
328330

329-
self.check_consistent_viewports()
330-
self.check_all_interfaces_in_view_content_exist()
331+
self.clean_view_content(
332+
view_content=self.view_content,
333+
hanging_protocol=self.hanging_protocol,
334+
)
331335

332-
def check_consistent_viewports(self):
333-
if self.view_content and self.hanging_protocol:
334-
if set(self.view_content.keys()) != {
335-
x["viewport_name"] for x in self.hanging_protocol.json
336+
@staticmethod
337+
def check_consistent_viewports(*, view_content, hanging_protocol):
338+
if view_content and hanging_protocol:
339+
if set(view_content.keys()) != {
340+
x["viewport_name"] for x in hanging_protocol.json
336341
}:
337342
raise ValidationError(
338343
"Image ports in view_content do not match "
339344
"those in the selected hanging protocol."
340345
)
341346

342-
def check_all_interfaces_in_view_content_exist(self):
343-
if not hasattr(self.view_content, "items"):
347+
@staticmethod
348+
def check_all_interfaces_in_view_content_exist(*, view_content):
349+
if not hasattr(view_content, "items"):
344350
raise ValidationError("View content is invalid")
345351

346-
for viewport, slugs in self.view_content.items():
352+
for viewport, slugs in view_content.items():
347353
viewport_interfaces = ComponentInterface.objects.filter(
348354
slug__in=slugs
349355
)
@@ -395,5 +401,14 @@ def check_all_interfaces_in_view_content_exist(self):
395401
f"{', '.join(i.slug for i in undisplayable_interfaces)}"
396402
)
397403

404+
@staticmethod
405+
def clean_view_content(*, view_content, hanging_protocol):
406+
HangingProtocolMixin.check_consistent_viewports(
407+
view_content=view_content, hanging_protocol=hanging_protocol
408+
)
409+
HangingProtocolMixin.check_all_interfaces_in_view_content_exist(
410+
view_content=view_content
411+
)
412+
398413
class Meta:
399414
abstract = True

app/grandchallenge/reader_studies/forms.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
MarkdownEditorInlineWidget,
5757
)
5858
from grandchallenge.groups.forms import UserGroupForm
59+
from grandchallenge.hanging_protocols.forms import ViewContentExampleMixin
5960
from grandchallenge.hanging_protocols.models import VIEW_CONTENT_SCHEMA
6061
from grandchallenge.reader_studies.models import (
6162
ANSWER_TYPE_TO_INTERACTIVE_ALGORITHM_CHOICES,
@@ -189,7 +190,9 @@ def clean(self):
189190
)
190191

191192

192-
class ReaderStudyUpdateForm(ReaderStudyCreateForm, ModelForm):
193+
class ReaderStudyUpdateForm(
194+
ReaderStudyCreateForm, ViewContentExampleMixin, ModelForm
195+
):
193196
class Meta(ReaderStudyCreateForm.Meta):
194197
fields = (
195198
"title",
@@ -254,19 +257,6 @@ class Meta(ReaderStudyCreateForm.Meta):
254257
),
255258
}
256259

257-
def __init__(self, *args, **kwargs):
258-
super().__init__(*args, **kwargs)
259-
interface_slugs = (
260-
self.instance.display_sets.exclude(values__isnull=True)
261-
.values_list("values__interface__slug", flat=True)
262-
.order_by()
263-
.distinct()
264-
)
265-
self.fields["view_content"].help_text += (
266-
" The following interfaces are used in your reader study: "
267-
f"{', '.join(interface_slugs)}."
268-
)
269-
270260

271261
class ReaderStudyCopyForm(Form):
272262
title = CharField(required=True)

0 commit comments

Comments
 (0)