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

Add set-tasks endpoint #567

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
88 changes: 85 additions & 3 deletions isic/studies/api.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,111 @@
from django.contrib.auth.models import User
from django.db import transaction
from django.db.models.query_utils import Q
from django.http.response import JsonResponse
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAdminUser

from isic.core.api import Conflict
from isic.core.permissions import IsicObjectPermissionsFilter, get_visible_objects
from isic.studies.models import Annotation, Study, StudyTask
from isic.studies.serializers import AnnotationSerializer, StudySerializer, StudyTaskSerializer
from isic.studies.serializers import (
AnnotationSerializer,
StudySerializer,
StudyTaskAssignmentSerializer,
StudyTaskSerializer,
)


class StudyTaskViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = StudyTaskSerializer
queryset = StudyTask.objects.all()
permission_classes = [IsAdminUser]
filter_backends = [IsicObjectPermissionsFilter]

swagger_schema = None


class StudyViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = StudySerializer
queryset = Study.objects.all()
permission_classes = [IsAdminUser]
queryset = Study.objects.prefetch_related('questions__choices', 'features').distinct()
filter_backends = [IsicObjectPermissionsFilter]

swagger_schema = None

@action(detail=True, methods=['delete'], pagination_class=None, url_path='delete-tasks')
def delete_tasks(self, request, *args, **kwargs):
study: Study = self.get_object()
if not request.user.has_perm('studies.modify_study', study):
raise PermissionDenied
elif study.tasks.filter(annotation__isnull=False).exists():
raise Conflict('Study has answered questions, tasks cannot be deleted.')
else:
# TODO: this will timeout for larger studies
study.tasks.all().delete()
return JsonResponse({})

@action(detail=True, methods=['post'], pagination_class=None, url_path='set-tasks')
def set_tasks(self, request, *args, **kwargs):
study: Study = self.get_object()
if not request.user.has_perm('studies.modify_study', study):
raise PermissionDenied
elif study.tasks.filter(annotation__isnull=False).exists():
raise Conflict('Study has answered questions, tasks cannot be overwritten.')

serializer = StudyTaskAssignmentSerializer(data=request.data, many=True, max_length=100)
serializer.is_valid(raise_exception=True)

isic_ids = [x['isic_id'] for x in serializer.validated_data]
identifier_filter = Q()
for data in serializer.validated_data:
identifier_filter |= Q(profile__hash_id__iexact=data['user_hash_id_or_email'])
identifier_filter |= Q(email__iexact=data['user_hash_id_or_email'])

requested_users = (
User.objects.select_related('profile').filter(is_active=True).filter(identifier_filter)
)
# create a lookup dictionary that keys users by their hash id and email
requested_users_lookup = {}
for user in requested_users:
requested_users_lookup[user.profile.hash_id] = user
requested_users_lookup[user.email] = user

requested_images = study.collection.images.filter(isic_id__in=isic_ids)
visible_images = get_visible_objects(
request.user, 'core.view_image', requested_images
).in_bulk(field_name='isic_id')

summary = {
'image_no_perms_or_does_not_exist': [],
'user_does_not_exist': [],
'succeeded': [],
}

with transaction.atomic():
for task_assignment in serializer.validated_data:
if task_assignment['user_hash_id_or_email'] not in requested_users_lookup:
summary['user_does_not_exist'].append(task_assignment['user_hash_id_or_email'])
elif task_assignment['isic_id'] not in visible_images:
summary['image_no_perms_or_does_not_exist'].append(task_assignment['isic_id'])
else:
StudyTask.objects.create(
study=study,
annotator=requested_users_lookup[task_assignment['user_hash_id_or_email']],
image=visible_images[task_assignment['isic_id']],
)
summary['succeeded'].append(
f'{task_assignment["isic_id"]}/{task_assignment["user_hash_id_or_email"]}'
)

return JsonResponse(summary)


class AnnotationViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = AnnotationSerializer
queryset = Annotation.objects.all()
permission_classes = [IsAdminUser]
filter_backends = [IsicObjectPermissionsFilter]

swagger_schema = None
23 changes: 21 additions & 2 deletions isic/studies/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,27 @@ class Meta:

class StudyPermissions:
model = Study
perms = ['view_study', 'view_study_results']
filters = {'view_study': 'view_study_list', 'view_study_results': 'view_study_results_list'}
perms = ['view_study', 'view_study_results', 'modify_study']
filters = {
'view_study': 'view_study_list',
'view_study_results': 'view_study_results_list',
'modify_study': 'modify_study_list',
}

@staticmethod
def modify_study_list(user_obj: User, qs: QuerySet[Study] | None = None) -> QuerySet[Study]:
qs: QuerySet[Study] = qs if qs is not None else Study._default_manager.all()

if user_obj.is_staff:
return qs
elif user_obj.is_authenticated:
return qs.filter(creator=user_obj)
else:
return qs.none()

@staticmethod
def modify_study(user_obj, obj):
return StudyPermissions.modify_study_list(user_obj).contains(obj)

@staticmethod
def view_study_results_list(
Expand Down
19 changes: 17 additions & 2 deletions isic/studies/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from rest_framework import serializers

from isic.core.constants import ISIC_ID_REGEX
from isic.studies.models import Annotation, Feature, Question, QuestionChoice, Study, StudyTask


Expand Down Expand Up @@ -32,7 +33,7 @@ class QuestionSerializer(serializers.ModelSerializer):

class Meta:
model = Question
fields = ['id', 'required', 'type', 'prompt', 'official', 'choices']
fields = ['id', 'type', 'prompt', 'official', 'choices']


class StudySerializer(serializers.ModelSerializer):
Expand All @@ -41,4 +42,18 @@ class StudySerializer(serializers.ModelSerializer):

class Meta:
model = Study
fields = ['id', 'created', 'creator', 'name', 'description', 'features', 'questions']
fields = [
'id',
'created',
'creator',
'name',
'description',
'public',
'features',
'questions',
]


class StudyTaskAssignmentSerializer(serializers.Serializer):
isic_id = serializers.RegexField(ISIC_ID_REGEX)
user_hash_id_or_email = serializers.CharField()
60 changes: 60 additions & 0 deletions isic/studies/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import pytest


@pytest.fixture
def public_study(study_factory, user_factory):
creator = user_factory()
owners = [creator] + [user_factory() for _ in range(2)]
return study_factory(public=True, creator=creator, owners=owners)


@pytest.fixture
def private_study(study_factory, user_factory):
creator = user_factory()
owners = [creator] + [user_factory() for _ in range(2)]
return study_factory(public=False, creator=creator, owners=owners)


@pytest.fixture
def private_study_and_guest(private_study, user_factory):
return private_study, user_factory()


@pytest.fixture
def private_study_and_annotator(private_study, user_factory, study_task_factory):
u = user_factory()
study_task_factory(annotator=u, study=private_study)
return private_study, u


@pytest.fixture
def private_study_and_owner(private_study):
return private_study, private_study.owners.first()


@pytest.fixture
def private_study_with_responses(study_factory, user_factory, response_factory):
# create a scenario for testing that a user can only see their responses and
# not another annotators.
study = study_factory(public=False)
u1, u2 = user_factory(), user_factory()
response_factory(
annotation__annotator=u1,
annotation__study=study,
annotation__task__annotator=u1,
annotation__task__study=study,
)
response_factory(
annotation__annotator=u2,
annotation__study=study,
annotation__task__annotator=u2,
annotation__task__study=study,
)
return study, u1, u2


@pytest.fixture
def study_task_with_user(study_task_factory, user_factory):
u = user_factory()
study_task = study_task_factory(annotator=u)
return study_task
44 changes: 44 additions & 0 deletions isic/studies/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from unittest import TestCase

import pytest

from isic.studies.models import StudyTask


@pytest.fixture
def study_with_images(user_factory, image_factory, collection_factory, study_factory):
user = user_factory()
collection = collection_factory(creator=user, public=True)
images = [image_factory(public=True) for _ in range(2)]
collection.images.add(*images)
study = study_factory(creator=user, collection=collection)
return study, images


@pytest.mark.django_db
def test_set_tasks(api_client, study_with_images, user_factory):
users = [user_factory() for _ in range(2)]
study, images = study_with_images
api_client.force_login(study.creator)

r = api_client.post(
f'/api/v2/studies/{study.pk}/set-tasks/',
[
{'isic_id': images[0].isic_id, 'user_hash_id_or_email': users[0].profile.hash_id},
{'isic_id': images[1].isic_id, 'user_hash_id_or_email': users[1].email},
# bad image
{'isic_id': 'ISIC_9999999', 'user_hash_id_or_email': users[1].email},
# bad user
{'isic_id': images[1].isic_id, 'user_hash_id_or_email': 'FAKEUSER'},
],
)
assert r.status_code == 200, r.json()
assert StudyTask.objects.count() == 2

tasks_actual = list(StudyTask.objects.all().values('study', 'annotator', 'image'))
tasks_expected = [
{'study': study.pk, 'annotator': users[0].pk, 'image': images[0].pk},
{'study': study.pk, 'annotator': users[1].pk, 'image': images[1].pk},
]
for task_actual, task_expected in zip(tasks_actual, tasks_expected):
TestCase().assertDictEqual(task_actual, task_expected)
Loading