Skip to content
Merged
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
48 changes: 31 additions & 17 deletions isic/studies/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@
from isic.studies.models import Question, Response, Study


def parse_user_identifiers(value: str) -> list[int]:
values = {e.strip() for e in value.splitlines() if e.strip()}
user_pks = set()

for email_or_hash_id in values:
user = User.objects.filter(
Q(is_active=True)
& (
Q(profile__hash_id__iexact=email_or_hash_id)
| Q(emailaddress__email__iexact=email_or_hash_id)
)
).first()
if not user:
raise ValidationError(f"Can't find any user with the identifier {email_or_hash_id}.")

user_pks.add(user.pk)

return list(user_pks)


class StudyTaskForm(forms.Form):
"""
A dynamically generated form for a StudyTask.
Expand Down Expand Up @@ -91,23 +111,7 @@ def __init__(self, *args, **kwargs):
annotators = forms.CharField()

def clean_annotators(self) -> list[int]:
value: str = self.cleaned_data["annotators"]
values = {e.strip() for e in value.splitlines()}
user_pks = set()

for email_or_hash_id in values:
user = User.objects.filter(
Q(is_active=True) & Q(profile__hash_id__iexact=email_or_hash_id)
| Q(emailaddress__email__iexact=email_or_hash_id)
).first()
if not user:
raise ValidationError(
f"Can't find any user with the identifier {email_or_hash_id}."
)

user_pks.add(user.pk)

return list(user_pks)
return parse_user_identifiers(self.cleaned_data["annotators"])

def clean_collection(self) -> bool:
value = self.cleaned_data["collection"]
Expand Down Expand Up @@ -140,3 +144,13 @@ class StudyEditForm(forms.Form):

name = fields["name"]
description = fields["description"]


class StudyAddAnnotatorsForm(forms.Form):
annotators = forms.CharField(
widget=forms.Textarea(attrs={"rows": 5}),
help_text="A list of email addresses or hash IDs (one per line) of the annotators to add.",
)

def clean_annotators(self) -> list[int]:
return parse_user_identifiers(self.cleaned_data["annotators"])
3 changes: 2 additions & 1 deletion isic/studies/templates/studies/partials/study_actions.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
<div x-show="open" class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-100 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
{% if can_edit %}
<div class="py-1" role="none">
<a href="{% url 'study-edit' study.pk %}" class="hover:bg-gray-100 hover:text-gray-900 text-gray-700 block px-4 py-2 text-sm" role="menuitem" tabindex="-1" id="menu-item-0">Edit Study</a>
<a href="{% url 'study-edit' study.pk %}" class="hover:bg-gray-100 hover:text-gray-900 text-gray-700 block px-4 py-2 text-sm" role="menuitem" tabindex="-1">Edit Study</a>
<a href="{% url 'study-add-annotators' study.pk %}" class="hover:bg-gray-100 hover:text-gray-900 text-gray-700 block px-4 py-2 text-sm" role="menuitem" tabindex="-1">Add Annotators</a>
</div>
{% endif %}

Expand Down
70 changes: 70 additions & 0 deletions isic/studies/templates/studies/study_add_annotators.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{% extends 'core/base.html' %}
{% load display %}

{% block content %}
<div class="space-y-8">
<div class="max-w-2xl">
<div class="heading-1">
Add Annotators to {{ study.name }}
</div>
</div>

{% if existing_annotators %}
<div>
<h2 class="text-lg font-medium text-gray-900 mb-4">Current Annotators</h2>
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg max-w-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Annotator
</th>
</tr>
</thead>
<tbody>
{% for annotator in existing_annotators %}
<tr class="{% cycle 'bg-white' 'bg-gray-50' %}">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{% with user_slug=annotator.profile.hash_id %}
{% if show_real_names %}
{{ annotator|user_nicename }} (User {{ user_slug }})
{% else %}
User {{ user_slug }}
{% endif %}
{% endwith %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-gray-500">No annotators have been added to this study yet.</p>
{% endif %}

<form class="space-y-6" method="post">
{% csrf_token %}

{{ form.non_field_errors }}

<div class="max-w-lg">
<label for="{{ form.annotators.html_name }}" class="block text-sm font-medium text-gray-700">
Add New Annotators
</label>
<div class="mt-1">
<textarea id="{{ form.annotators.html_name }}" name="{{ form.annotators.html_name }}" rows="5" class="border outline-none shadow-sm p-4 block w-full focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm border-gray-400 rounded-md" placeholder="user@example.com&#10;ABC12&#10;another@example.com"></textarea>
<p class="mt-2 text-sm text-gray-500">{{ form.annotators.help_text }}</p>
{{ form.annotators.errors }}
</div>
</div>

<div class="flex justify-end max-w-lg">
<a href="{% url 'study-detail' study.pk %}" class="btn btn-secondary mr-3">Cancel</a>
<button type="submit" class="btn btn-primary">
Add Annotators
</button>
</div>
</form>
</div>
{% endblock %}
3 changes: 3 additions & 0 deletions isic/studies/tests/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def test_study_list_objects_permissions_annotator(client):
"view_name",
[
"study-edit",
"study-add-annotators",
"study-detail",
"study-download-responses",
],
Expand All @@ -115,6 +116,7 @@ def test_study_view_permissions(view_name, client_, status):
"view_name",
[
"study-edit",
"study-add-annotators",
"study-detail",
"study-download-responses",
],
Expand All @@ -134,6 +136,7 @@ def test_study_view_permissions_owner(view_name, client):
("view_name", "status"),
[
("study-edit", 403),
("study-add-annotators", 403),
("study-detail", 200),
("study-download-responses", 403),
],
Expand Down
19 changes: 19 additions & 0 deletions isic/studies/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.utils import timezone
import pytest

from isic.factories import UserFactory
from isic.studies.models import Question
from isic.studies.tests.factories import (
AnnotationFactory,
Expand Down Expand Up @@ -71,3 +72,21 @@ def test_study_task_detail_post_number_question(client, input_value, expected_va
assert response.choice is None
assert response.value == expected_value
assert type(response.value) is type(expected_value)


@pytest.mark.django_db
def test_study_add_annotators(client, django_capture_on_commit_callbacks, image_factory):
study_task = StudyTaskFactory.create()
study = study_task.study
study.collection.images.add(image_factory())
new_user = UserFactory.create()

client.force_login(study.creator)
with django_capture_on_commit_callbacks(execute=True):
r = client.post(
reverse("study-add-annotators", args=[study.pk]),
{"annotators": new_user.email},
)
assert r.status_code == 302

assert study.tasks.filter(annotator=new_user).exists()
2 changes: 2 additions & 0 deletions isic/studies/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from isic.studies.views import (
annotation_detail,
study_add_annotators,
study_create_view,
study_detail,
study_edit,
Expand All @@ -16,6 +17,7 @@
path("studies/", study_list, name="study-list"),
path("studies/create/", study_create_view, name="study-create"),
path("studies/edit/<int:pk>/", study_edit, name="study-edit"),
path("studies/<int:pk>/add-annotators/", study_add_annotators, name="study-add-annotators"),
path("studies/<int:pk>/", study_detail, name="study-detail"),
path("studies/tasks/<int:pk>/", study_task_detail, name="study-task-detail"),
path(
Expand Down
46 changes: 46 additions & 0 deletions isic/studies/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
BaseStudyForm,
CustomQuestionForm,
OfficialQuestionForm,
StudyAddAnnotatorsForm,
StudyEditForm,
StudyTaskForm,
)
Expand Down Expand Up @@ -178,6 +179,51 @@ def study_edit(request, pk):
return render(request, "studies/study_edit.html", {"form": form, "study": study})


@needs_object_permission("studies.edit_study", (Study, "pk", "pk"))
def study_add_annotators(request, pk):
study = get_object_or_404(Study, pk=pk)

existing_annotators = (
User.objects.filter(pk__in=study.tasks.values("annotator").distinct())
.select_related("profile")
.order_by("last_name", "first_name")
)
existing_user_pks = set(existing_annotators.values_list("pk", flat=True))

show_real_names = request.user.is_staff or request.user in study.owners.all()

if request.method == "POST":
form = StudyAddAnnotatorsForm(request.POST)
if form.is_valid():
additional_user_pks = form.cleaned_data["annotators"]
new_user_pks = [pk for pk in additional_user_pks if pk not in existing_user_pks]

if new_user_pks:
populate_study_tasks_task.delay_on_commit(study.pk, new_user_pks)
messages.add_message(
request, messages.INFO, "Adding annotator(s), this may take a few minutes."
)
else:
messages.add_message(
request, messages.WARNING, "All specified users are already annotators."
)

return HttpResponseRedirect(reverse("study-detail", args=[study.pk]))
else:
form = StudyAddAnnotatorsForm()

return render(
request,
"studies/study_add_annotators.html",
{
"study": study,
"form": form,
"existing_annotators": existing_annotators,
"show_real_names": show_real_names,
},
)


@staff_member_required
def view_mask(request, markup_id):
markup = get_object_or_404(Markup.objects.values("mask"), pk=markup_id)
Expand Down