Skip to content

Commit

Permalink
Add set-tasks endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
danlamanna committed Apr 4, 2022
1 parent 7dc2cad commit 7ee8521
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 76 deletions.
8 changes: 0 additions & 8 deletions isic/core/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from collections import defaultdict
import json

from django.conf import settings
Expand Down Expand Up @@ -27,13 +26,6 @@
from isic.studies.models import Annotation, Study, StudyTask


def key_by(sequence, f):
r = defaultdict(list)
for item in sequence:
r[f(item)].append(item)
return dict(r)


@needs_object_permission('auth.view_staff')
def staff_list(request):
users = User.objects.filter(is_staff=True).order_by('email')
Expand Down
76 changes: 73 additions & 3 deletions isic/studies/api.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,99 @@
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.distinct()
filter_backends = [IsicObjectPermissionsFilter]

swagger_schema = None

@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

0 comments on commit 7ee8521

Please sign in to comment.