Skip to content

Commit d6a868e

Browse files
authored
Revert "Use window annotation for standard index" (#4117)
Reverts #4098 for breaking reader studies.
1 parent 19cbf3a commit d6a868e

5 files changed

Lines changed: 75 additions & 160 deletions

File tree

app/grandchallenge/core/utils/query.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ def index(queryset, obj):
1616
def set_seed(seed):
1717
# Note: this only works for postgres, if we ever switch dbs, this
1818
# may need changing.
19-
with connection.cursor() as cursor:
20-
cursor.execute(f"SELECT setseed({seed});")
19+
cursor = connection.cursor()
20+
cursor.execute(f"SELECT setseed({seed});")
21+
cursor.close()

app/grandchallenge/reader_studies/models.py

Lines changed: 14 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
)
1616
from django.db import models
1717
from django.db.models import Avg, Count, Q, Sum
18-
from django.db.models.functions.window import RowNumber
1918
from django.db.models.signals import post_delete
2019
from django.dispatch import receiver
2120
from django.utils.functional import cached_property
@@ -54,7 +53,6 @@
5453
AccessRequestHandlingOptions,
5554
process_access_request,
5655
)
57-
from grandchallenge.core.utils.query import set_seed
5856
from grandchallenge.core.validators import JSONValidator
5957
from grandchallenge.core.vendored.django.validators import StepValueValidator
6058
from grandchallenge.hanging_protocols.models import (
@@ -892,56 +890,6 @@ def delete_reader_study_groups_hook(*_, instance: ReaderStudy, using, **__):
892890
pass
893891

894892

895-
class DisplaySetQuerySet(models.QuerySet):
896-
def with_row_number(self):
897-
return self.annotate(
898-
row_number=models.Window(
899-
expression=RowNumber(),
900-
partition_by=models.F("reader_study"),
901-
order_by=("order", "created"),
902-
)
903-
)
904-
905-
def with_shuffled_index(self, *, reader_study, seed):
906-
"""
907-
Sets the shuffled index on each object
908-
909-
This requires fetching all pks for all of the display sets
910-
in the reader study first
911-
"""
912-
if not isinstance(seed, int) or seed < 1:
913-
raise ValueError("Invalid seed")
914-
915-
queryset = self.filter(reader_study=reader_study)
916-
917-
set_seed(1 / seed)
918-
919-
queryset = queryset.annotate(
920-
shuffled_index=models.Case(
921-
*[
922-
models.When(pk=pk, then=models.Value(idx))
923-
for idx, pk in enumerate(
924-
queryset.order_by("?").values_list("pk", flat=True)
925-
)
926-
],
927-
output_field=models.IntegerField(),
928-
)
929-
).order_by("shuffled_index")
930-
931-
return queryset
932-
933-
def with_answer_count(self, user):
934-
return self.annotate(
935-
answer_count=Count(
936-
"answers",
937-
filter=Q(
938-
answers__is_ground_truth=False,
939-
answers__creator=user,
940-
),
941-
)
942-
)
943-
944-
945893
class DisplaySet(
946894
CIVSetStringRepresentationMixin,
947895
CIVSetObjectPermissionsMixin,
@@ -957,18 +905,6 @@ class DisplaySet(
957905
order = models.PositiveIntegerField(default=0)
958906
title = models.CharField(max_length=255, default="", blank=True)
959907

960-
objects = DisplaySetQuerySet.as_manager()
961-
962-
class Meta:
963-
ordering = ("order", "created")
964-
constraints = [
965-
models.UniqueConstraint(
966-
fields=["title", "reader_study"],
967-
name="unique_display_set_title",
968-
condition=~Q(title=""),
969-
)
970-
]
971-
972908
def assign_permissions(self):
973909
assign_perm(
974910
self.delete_perm,
@@ -991,6 +927,16 @@ def assign_permissions(self):
991927
self,
992928
)
993929

930+
class Meta:
931+
ordering = ("order", "created")
932+
constraints = [
933+
models.UniqueConstraint(
934+
fields=["title", "reader_study"],
935+
name="unique_display_set_title",
936+
condition=~Q(title=""),
937+
)
938+
]
939+
994940
@cached_property
995941
def is_editable(self):
996942
return not self.answers.exists()
@@ -1038,6 +984,10 @@ def description(self) -> str:
1038984
else:
1039985
return ""
1040986

987+
@property
988+
def standard_index(self) -> int:
989+
return [*self.reader_study.display_sets.all()].index(self)
990+
1041991
@property
1042992
def update_url(self):
1043993
return reverse(

app/grandchallenge/reader_studies/serializers.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,19 +115,13 @@ class DisplaySetSerializer(HyperlinkedModelSerializer):
115115

116116
def get_index(self, obj) -> int | None:
117117
if obj.reader_study.shuffle_hanging_list:
118-
return obj.shuffled_index
119-
else:
120118
try:
121-
row_number = obj.row_number
122-
except AttributeError:
123-
# The annotation wasn't made when getting this object
124-
row_number = (
125-
DisplaySet.objects.with_row_number()
126-
.get(pk=obj.pk)
127-
.row_number
128-
)
129-
130-
return row_number - 1
119+
return self.context["view"].randomized_qs.index(obj)
120+
except ValueError:
121+
# The list is empty if no reader study is specified.
122+
return None
123+
else:
124+
return obj.standard_index
131125

132126
class Meta:
133127
model = DisplaySet

app/grandchallenge/reader_studies/views.py

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.contrib.messages.views import SuccessMessageMixin
1212
from django.core.exceptions import PermissionDenied, ValidationError
1313
from django.db import transaction
14+
from django.db.models import Count, Q
1415
from django.forms import Form
1516
from django.forms.utils import ErrorList
1617
from django.http import (
@@ -73,6 +74,7 @@
7374
from grandchallenge.core.renderers import PaginatedCSVRenderer
7475
from grandchallenge.core.templatetags.random_encode import random_encode
7576
from grandchallenge.core.utils import strtobool
77+
from grandchallenge.core.utils.query import set_seed
7678
from grandchallenge.core.views import PermissionRequestUpdate
7779
from grandchallenge.datatables.views import Column
7880
from grandchallenge.groups.forms import EditorsForm
@@ -1018,11 +1020,11 @@ class DisplaySetViewSet(
10181020
serializer_class = DisplaySetSerializer
10191021
queryset = (
10201022
DisplaySet.objects.all()
1021-
.with_row_number()
10221023
.select_related("reader_study__hanging_protocol")
10231024
.prefetch_related(
10241025
"values__image",
10251026
"values__interface",
1027+
"reader_study__display_sets",
10261028
"reader_study__optional_hanging_protocols",
10271029
)
10281030
)
@@ -1033,6 +1035,7 @@ class DisplaySetViewSet(
10331035
*api_settings.DEFAULT_RENDERER_CLASSES,
10341036
PaginatedCSVRenderer,
10351037
)
1038+
randomized_qs = []
10361039

10371040
@property
10381041
def reader_study(self):
@@ -1043,26 +1046,18 @@ def reader_study(self):
10431046
def get_serializer_class(self):
10441047
if self.action in ["partial_update", "update", "create"]:
10451048
return DisplaySetPostSerializer
1046-
else:
1047-
return DisplaySetSerializer
1048-
1049-
def get_queryset(self):
1050-
queryset = super().get_queryset()
1049+
return DisplaySetSerializer
10511050

1051+
def list(self, request, *args, **kwargs):
1052+
queryset = self.filter_queryset(self.get_queryset())
10521053
# Note: if more fields besides 'reader_study' are added to the
10531054
# filter_set fields, we cannot call super anymore before randomizing
10541055
# as we only want to filter out the display sets for a specific
10551056
# reader study.
10561057
reader_study = self.reader_study
1057-
1058-
if reader_study:
1059-
if reader_study.shuffle_hanging_list:
1060-
queryset = queryset.with_shuffled_index(
1061-
reader_study=reader_study, seed=self.request.user.pk
1062-
)
1063-
else:
1064-
queryset = queryset.filter(reader_study=reader_study)
1065-
1058+
if reader_study and reader_study.shuffle_hanging_list:
1059+
queryset = queryset.filter(reader_study=reader_study)
1060+
queryset = self.create_randomized_qs(queryset=queryset)
10661061
unanswered_by_user = strtobool(
10671062
self.request.query_params.get("unanswered_by_user", "False")
10681063
)
@@ -1073,10 +1068,8 @@ def get_queryset(self):
10731068
"Specifying a user is only possible when retrieving unanswered"
10741069
" display sets."
10751070
)
1076-
10771071
if username:
10781072
user = get_user_model().objects.filter(username=username).get()
1079-
10801073
if user != self.request.user and not self.request.user.has_perm(
10811074
"change_readerstudy", self.reader_study
10821075
):
@@ -1093,30 +1086,56 @@ def get_queryset(self):
10931086
"Please provide a reader study when filtering for "
10941087
"unanswered display_sets."
10951088
)
1096-
10971089
answerable_question_count = reader_study.answerable_question_count
1098-
queryset = queryset.with_answer_count(user=user).exclude(
1099-
answer_count__gte=answerable_question_count,
1090+
queryset = (
1091+
queryset.annotate(
1092+
answer_count=Count(
1093+
"answers",
1094+
filter=Q(
1095+
answers__is_ground_truth=False,
1096+
answers__creator=user,
1097+
),
1098+
)
1099+
)
1100+
.exclude(
1101+
answer_count__gte=answerable_question_count,
1102+
)
1103+
.order_by("order", "created")
11001104
)
1105+
# Because the filtering has changed the list, we can no longer
1106+
# reapply .order_by("?"), as the ordering would not be consistent
1107+
# with the ordering of the full list. Instead, we use the
1108+
# previously saved randomized_qs and filter the proper items
1109+
# out of it.
1110+
if reader_study and reader_study.shuffle_hanging_list:
1111+
pks = queryset.values_list("pk", flat=True)
1112+
queryset = [x for x in self.randomized_qs if x.pk in pks]
11011113

1102-
if reader_study.shuffle_hanging_list:
1103-
queryset = queryset.order_by("shuffled_index")
1104-
else:
1105-
queryset = queryset.order_by("order", "created")
1114+
page = self.paginate_queryset(queryset)
1115+
if page is not None:
1116+
serializer = self.get_serializer(page, many=True)
1117+
return self.get_paginated_response(serializer.data)
11061118

1107-
return queryset
1119+
serializer = self.get_serializer(queryset, many=True)
1120+
return Response(serializer.data)
11081121

11091122
def get_object(self):
11101123
obj = super().get_object()
1111-
1124+
# retrieve the full queryset and save its shuffled version to later
1125+
# determine the shuffled index for this object
11121126
if obj.reader_study.shuffle_hanging_list:
1113-
queryset = self.filter_queryset(self.get_queryset())
1114-
queryset = queryset.with_shuffled_index(
1115-
reader_study=obj.reader_study, seed=self.request.user.pk
1116-
)
1117-
return queryset.get(pk=obj.pk)
1118-
else:
1119-
return obj
1127+
queryset = self.get_queryset()
1128+
queryset = super().filter_queryset(queryset)
1129+
queryset = queryset.filter(reader_study=obj.reader_study)
1130+
self.create_randomized_qs(queryset=queryset)
1131+
return obj
1132+
1133+
def create_randomized_qs(self, queryset):
1134+
set_seed(1 / int(self.request.user.pk))
1135+
queryset = queryset.order_by("?")
1136+
# Save the queryset to determine each item's index in the serializer
1137+
self.randomized_qs = list(queryset)
1138+
return queryset
11201139

11211140

11221141
class QuestionViewSet(ReadOnlyModelViewSet):

app/tests/reader_studies_tests/test_models.py

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from grandchallenge.reader_studies.models import (
1717
Answer,
1818
AnswerType,
19-
DisplaySet,
2019
Question,
2120
QuestionWidgetKindChoices,
2221
ReaderStudy,
@@ -1277,51 +1276,3 @@ def test_reader_study_not_launchable_when_max_credits_consumed():
12771276
assert reader_study.session_utilizations.first().credits_consumed == 500
12781277
assert reader_study.credits_consumed == 500
12791278
assert not reader_study.is_launchable
1280-
1281-
1282-
@pytest.mark.django_db
1283-
def test_with_row_number():
1284-
rs = ReaderStudyFactory()
1285-
ds1, ds2, ds3 = DisplaySetFactory.create_batch(3, reader_study=rs)
1286-
1287-
# Duplicate order should return unique indices
1288-
ds2.order = ds1.order
1289-
ds2.save()
1290-
1291-
ds4 = DisplaySetFactory()
1292-
1293-
# Test global access
1294-
queryset = DisplaySet.objects.with_row_number()
1295-
assert [*queryset] == [ds1, ds2, ds4, ds3]
1296-
assert {ds: ds.row_number for ds in queryset} == {
1297-
ds1: 1,
1298-
ds2: 2,
1299-
ds3: 3,
1300-
ds4: 1,
1301-
}
1302-
1303-
# Filtering should return the same indices
1304-
queryset = DisplaySet.objects.with_row_number().filter(reader_study=rs)
1305-
assert {ds: ds.row_number for ds in queryset} == {
1306-
ds1: 1,
1307-
ds2: 2,
1308-
ds3: 3,
1309-
}
1310-
1311-
# Getting a particular display set should return the correct order
1312-
last_display_set = (
1313-
DisplaySet.objects.with_row_number().filter(reader_study=rs).last()
1314-
)
1315-
assert last_display_set.row_number == 3
1316-
1317-
# Changing the order of the queryset should not change the index
1318-
queryset = DisplaySet.objects.with_row_number().order_by(
1319-
"-order", "-created"
1320-
)
1321-
assert [*queryset] == [ds3, ds4, ds2, ds1]
1322-
assert {ds: ds.row_number for ds in queryset} == {
1323-
ds1: 1,
1324-
ds2: 2,
1325-
ds3: 3,
1326-
ds4: 1,
1327-
}

0 commit comments

Comments
 (0)