diff --git a/isic/core/templates/core/widgets/diagnosis_picker.html b/isic/core/templates/core/widgets/diagnosis_picker.html new file mode 100644 index 00000000..bac8d813 --- /dev/null +++ b/isic/core/templates/core/widgets/diagnosis_picker.html @@ -0,0 +1,258 @@ +
+ + + {{ diagnosis_values|json_script:"diagnosis-values" }} + + + +
+

Recent Diagnoses

+
+ +
+
+ +
+
+
+ +
+ +
+ +
+ + diff --git a/isic/studies/forms.py b/isic/studies/forms.py index 3d91d36c..3cf93089 100644 --- a/isic/studies/forms.py +++ b/isic/studies/forms.py @@ -1,10 +1,11 @@ from django import forms from django.contrib.auth.models import User from django.core.exceptions import ValidationError +from django.db.models import Count from django.db.models.query import QuerySet from django.db.models.query_utils import Q -from isic.studies.models import Question, Study +from isic.studies.models import Question, Response, Study class StudyTaskForm(forms.Form): @@ -22,6 +23,42 @@ def __init__(self, *args, **kwargs): # Note: questions must be annotated with a required attribute questions: QuerySet[Question] = kwargs.pop("questions") self.questions = {x.pk: x for x in questions} + + num_diagnosis_questions = len( + [question for question in questions if question.type == Question.QuestionType.DIAGNOSIS] + ) + if num_diagnosis_questions > 1: + # this is a hack because passing a per-question version of most frequent diagnoses is + # unreasonably difficult. + raise ValueError("Only one diagnosis question is allowed per study.") + elif num_diagnosis_questions == 1: # noqa: RET506 + # the study and user are necessary for diagnosis questions in order to compute + # the most frequently used diagnosis. + self.study = kwargs.pop("study") + self.user = kwargs.pop("user") + + self.most_frequent_diagnoses = list( + Response.objects.filter( + question=next( + question + for question in questions + if question.type == Question.QuestionType.DIAGNOSIS + ), + annotation__study=self.study, + annotation__annotator=self.user, + ) + .values("choice", "choice__text") + .alias(count=Count("choice")) + .order_by("-count") + ) + + # remove study/user from kwargs before passing to super + if "study" in kwargs: + del kwargs["study"] + + if "user" in kwargs: + del kwargs["user"] + super().__init__(*args, **kwargs) for question in questions: # field names for django forms must be strings diff --git a/isic/studies/migrations/0002_alter_question_type.py b/isic/studies/migrations/0002_alter_question_type.py new file mode 100644 index 00000000..e70c94af --- /dev/null +++ b/isic/studies/migrations/0002_alter_question_type.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.2 on 2024-11-13 17:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("studies", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="question", + name="type", + field=models.CharField( + choices=[ + ("select", "Select"), + ("number", "Number"), + ("diagnosis", "Diagnosis"), + ], + default="select", + max_length=9, + ), + ), + ] diff --git a/isic/studies/migrations/0003_alter_questionchoice_text.py b/isic/studies/migrations/0003_alter_questionchoice_text.py new file mode 100644 index 00000000..9d82bd9f --- /dev/null +++ b/isic/studies/migrations/0003_alter_questionchoice_text.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.3 on 2024-11-21 14:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("studies", "0002_alter_question_type"), + ] + + operations = [ + migrations.AlterField( + model_name="questionchoice", + name="text", + field=models.CharField(max_length=255), + ), + ] diff --git a/isic/studies/models.py b/isic/studies/models.py index 41dac2d3..40c05cf9 100644 --- a/isic/studies/models.py +++ b/isic/studies/models.py @@ -26,15 +26,17 @@ from isic.core.models import Image from isic.core.models.collection import Collection from isic.core.storages.utils import generate_upload_to +from isic.studies.widgets import DiagnosisPicker class Question(TimeStampedModel): class QuestionType(models.TextChoices): SELECT = "select", "Select" NUMBER = "number", "Number" + DIAGNOSIS = "diagnosis", "Diagnosis" prompt = models.CharField(max_length=400) - type = models.CharField(max_length=6, choices=QuestionType.choices, default=QuestionType.SELECT) + type = models.CharField(max_length=9, choices=QuestionType.choices, default=QuestionType.SELECT) official = models.BooleanField() # TODO: maybe add a default field @@ -51,7 +53,7 @@ class Meta(TimeStampedModel.Meta): def __str__(self) -> str: return self.prompt - def to_form_field(self, *, required: bool): + def to_form_field(self, *, required: bool) -> ChoiceField | FormCharField: if self.type == self.QuestionType.SELECT: return ChoiceField( required=required, @@ -59,12 +61,19 @@ def to_form_field(self, *, required: bool): label=self.prompt, widget=RadioSelect, ) - if self.type == self.QuestionType.NUMBER: + elif self.type == self.QuestionType.NUMBER: # noqa: RET505 # TODO: Use floatfield/intfield return FormCharField( required=required, label=self.prompt, ) + elif self.type == self.QuestionType.DIAGNOSIS: + return ChoiceField( + required=required, + choices=[(choice.pk, choice.text) for choice in self.choices.all()], + label=self.prompt, + widget=DiagnosisPicker, + ) def save(self, **kwargs): from isic.studies.models import Annotation @@ -83,7 +92,7 @@ class Meta(TimeStampedModel.Meta): unique_together = [["question", "text"]] question = models.ForeignKey(Question, related_name="choices", on_delete=models.CASCADE) - text = models.CharField(max_length=100) + text = models.CharField(max_length=255) def __str__(self) -> str: return self.text diff --git a/isic/studies/templates/studies/study_task_detail.html b/isic/studies/templates/studies/study_task_detail.html index 69b42b35..dfc0d15c 100644 --- a/isic/studies/templates/studies/study_task_detail.html +++ b/isic/studies/templates/studies/study_task_detail.html @@ -38,8 +38,15 @@ {% if study_task.complete %} You've finished this task. {% else %} + {% if form.most_frequent_diagnoses %} + {{ form.most_frequent_diagnoses|json_script:"most-frequent-diagnoses" }} + {% endif %} +
+ {{ form.media }} + {% csrf_token %} + {{ form.non_field_errors }} {% for hidden in form.hidden_fields %} @@ -53,7 +60,7 @@ {{ field }}
{% endfor %} - diff --git a/isic/studies/views.py b/isic/studies/views.py index 4ad5dc4d..6c28ca6f 100644 --- a/isic/studies/views.py +++ b/isic/studies/views.py @@ -302,7 +302,9 @@ def study_task_detail(request, pk): return maybe_redirect_to_next_study_task(request.user, study_task.study) if request.method == "POST": - form = StudyTaskForm(request.POST, questions=questions) + form = StudyTaskForm( + request.POST, questions=questions, study=study_task.study, user=request.user + ) if form.is_valid(): with transaction.atomic(): annotation = Annotation.objects.create( @@ -325,7 +327,12 @@ def study_task_detail(request, pk): return maybe_redirect_to_next_study_task(request.user, study_task.study) else: - form = StudyTaskForm(initial={"start_time": timezone.now()}, questions=questions) + form = StudyTaskForm( + initial={"start_time": timezone.now()}, + questions=questions, + study=study_task.study, + user=request.user, + ) context = { "study_task": study_task, diff --git a/isic/studies/widgets.py b/isic/studies/widgets.py new file mode 100644 index 00000000..3c45d38b --- /dev/null +++ b/isic/studies/widgets.py @@ -0,0 +1,14 @@ +from typing import Any + +from django import forms + + +class DiagnosisPicker(forms.Select): + template_name = "core/widgets/diagnosis_picker.html" + + def get_context(self, name: str, value: Any, attrs: dict[str, Any] | None) -> dict[str, Any]: + context = super().get_context(name, value, attrs) + # store the choice values for an easier way to perform template + # rendering of the entire DiagnosisEnum. + context["diagnosis_values"] = {choice[1]: choice[0] for choice in self.choices} + return context diff --git a/node-src/styles.pcss b/node-src/styles.pcss index 613ef0ad..d18b25b6 100644 --- a/node-src/styles.pcss +++ b/node-src/styles.pcss @@ -2,7 +2,8 @@ @tailwind components; @tailwind utilities; -select#user-selection, select#additional-collections-selection { +select#user-selection, +select#additional-collections-selection { width: 100%; height: 4em; } @@ -14,17 +15,17 @@ select#user-selection, select#additional-collections-selection { } a { - @apply text-primary; + @apply text-primary; - &:hover { - @apply text-primary-focus; + &:hover { + @apply text-primary-focus; } } a.disabled { pointer-events: none; cursor: default; - opacity: .5; + opacity: 0.5; } .heading-1 { @@ -70,7 +71,6 @@ select#user-selection, select#additional-collections-selection { } } - /* spinner code, see https://loading.io/css/ */ .lds-ring, .lds-ring div { @@ -111,3 +111,117 @@ select#user-selection, select#additional-collections-selection { } } /* spinner code end */ + +.diagnosis-picker { +.search-container { + margin-bottom: 20px; +} + +#search-input { + width: 100%; + padding: 8px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.tree-toggle { + cursor: pointer; + width: 20px; + display: inline-block; +} + +.tree { + margin: 20px 0 20px; +} + +.tree > ul { + padding-left: 0 !important; +} + +.tree ul { + list-style-type: none; + padding-left: 20px; +} + +.tree li { + margin: 0; + position: relative; +} + +.diagnosis { + cursor: pointer; + padding: 2px 5px; + border-radius: 3px; +} + +.diagnosis:hover { + background-color: #f0f0f0; +} + +.selected { + background-color: #e6f3ff; +} + +#selection { + margin: 20px 0 20px 0; + padding: 15px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #def; +} + +.hidden { + display: none; +} + +.match { + background-color: #fff3cd; +} + +} + + +.quick-select { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px; +} + +.quick-select h3 { + margin: 0 0 12px 0; + color: #475569; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.quick-select-list { + display: grid; + gap: 8px; +} + +.quick-select-item { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + color: #1e293b; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; +} + +.quick-select-item:hover { + background: #f0f4f8; +} + +.quick-select-item.quick-select-selected { + background-color: #def; +}