diff --git a/isic/studies/forms.py b/isic/studies/forms.py
index 473bc2e3..448e3534 100644
--- a/isic/studies/forms.py
+++ b/isic/studies/forms.py
@@ -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.
@@ -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"]
@@ -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"])
diff --git a/isic/studies/templates/studies/partials/study_actions.html b/isic/studies/templates/studies/partials/study_actions.html
index 64566a33..0f193d92 100644
--- a/isic/studies/templates/studies/partials/study_actions.html
+++ b/isic/studies/templates/studies/partials/study_actions.html
@@ -8,7 +8,8 @@
{% if can_edit %}
{% endif %}
diff --git a/isic/studies/templates/studies/study_add_annotators.html b/isic/studies/templates/studies/study_add_annotators.html
new file mode 100644
index 00000000..0c1b62b7
--- /dev/null
+++ b/isic/studies/templates/studies/study_add_annotators.html
@@ -0,0 +1,70 @@
+{% extends 'core/base.html' %}
+{% load display %}
+
+{% block content %}
+
+
+
+ Add Annotators to {{ study.name }}
+
+
+
+ {% if existing_annotators %}
+
+
Current Annotators
+
+
+
+
+ |
+ Annotator
+ |
+
+
+
+ {% for annotator in existing_annotators %}
+
+ |
+ {% with user_slug=annotator.profile.hash_id %}
+ {% if show_real_names %}
+ {{ annotator|user_nicename }} (User {{ user_slug }})
+ {% else %}
+ User {{ user_slug }}
+ {% endif %}
+ {% endwith %}
+ |
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
No annotators have been added to this study yet.
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/isic/studies/tests/test_permissions.py b/isic/studies/tests/test_permissions.py
index eea6db93..2c64b22c 100644
--- a/isic/studies/tests/test_permissions.py
+++ b/isic/studies/tests/test_permissions.py
@@ -91,6 +91,7 @@ def test_study_list_objects_permissions_annotator(client):
"view_name",
[
"study-edit",
+ "study-add-annotators",
"study-detail",
"study-download-responses",
],
@@ -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",
],
@@ -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),
],
diff --git a/isic/studies/tests/test_views.py b/isic/studies/tests/test_views.py
index 5c8e1078..e2bc30f4 100644
--- a/isic/studies/tests/test_views.py
+++ b/isic/studies/tests/test_views.py
@@ -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,
@@ -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()
diff --git a/isic/studies/urls.py b/isic/studies/urls.py
index e68ce58d..b70e8d49 100644
--- a/isic/studies/urls.py
+++ b/isic/studies/urls.py
@@ -2,6 +2,7 @@
from isic.studies.views import (
annotation_detail,
+ study_add_annotators,
study_create_view,
study_detail,
study_edit,
@@ -16,6 +17,7 @@
path("studies/", study_list, name="study-list"),
path("studies/create/", study_create_view, name="study-create"),
path("studies/edit/
/", study_edit, name="study-edit"),
+ path("studies//add-annotators/", study_add_annotators, name="study-add-annotators"),
path("studies//", study_detail, name="study-detail"),
path("studies/tasks//", study_task_detail, name="study-task-detail"),
path(
diff --git a/isic/studies/views.py b/isic/studies/views.py
index 5ef03cc2..b9e580f5 100644
--- a/isic/studies/views.py
+++ b/isic/studies/views.py
@@ -27,6 +27,7 @@
BaseStudyForm,
CustomQuestionForm,
OfficialQuestionForm,
+ StudyAddAnnotatorsForm,
StudyEditForm,
StudyTaskForm,
)
@@ -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)