From 46964625ef14a08018a1a78dcd072c4f9da56c26 Mon Sep 17 00:00:00 2001 From: witold Date: Mon, 1 Apr 2024 14:23:02 +0200 Subject: [PATCH] feat: make institution field use ROR --- lab/forms.py | 16 +-- lab/migrations/0033_institution_ror_id.py | 24 +++++ ...titution_country_alter_institution_name.py | 25 +++++ lab/models/participation.py | 14 ++- lab/static/css/admin/project-admin.css | 18 +++- .../institution-autocomplete-widget.css | 23 ++++ .../institution-autocomplete-widget.js | 100 +++++++++++++++--- .../institution_autocomplete_widget.html | 13 ++- .../forms/test_base_participation_form.py | 29 +++-- lab/tests/widgets/__init__.py | 0 .../test_institution_auto_complete_widget.py | 26 +++++ lab/widgets.py | 22 ++-- 12 files changed, 253 insertions(+), 57 deletions(-) create mode 100644 lab/migrations/0033_institution_ror_id.py create mode 100644 lab/migrations/0034_alter_institution_country_alter_institution_name.py create mode 100644 lab/static/css/widgets/institution-autocomplete-widget.css create mode 100644 lab/tests/widgets/__init__.py create mode 100644 lab/tests/widgets/test_institution_auto_complete_widget.py diff --git a/lab/forms.py b/lab/forms.py index d21e3b630..3414f921b 100644 --- a/lab/forms.py +++ b/lab/forms.py @@ -32,6 +32,8 @@ def __init__( initial = {**(initial or {}), "user": instance.user.email} super().__init__(initial=initial, instance=instance, **kwargs) self.fields["user"].widget.attrs["placeholder"] = _("Email") + if instance: + self.fields["institution"].widget.instance = instance.institution def try_populate_institution(self): if not self.data.get(f"{self.prefix}-institution") and self.data.get( @@ -43,6 +45,7 @@ def try_populate_institution(self): ) = models.Institution.objects.get_or_create( name=self.data[f"{self.prefix}-institution__name"], country=self.data.get(f"{self.prefix}-institution__country"), + ror_id=self.data.get(f"{self.prefix}-institution__ror_id"), ) self.data[f"{self.prefix}-institution"] = institution.pk @@ -59,6 +62,7 @@ def clean(self) -> dict[str, Any]: def save(self, commit: bool = ...) -> models.Participation: is_new = self.instance.pk is None + self.instance.is_leader = False instance = super().save(commit=commit) if is_new: user: User = instance.user @@ -71,13 +75,7 @@ def save(self, commit: bool = ...) -> models.Participation: class Meta: model = models.Participation fields = ("user", "institution") - widgets = { - "institution": widgets.InstitutionAutoCompleteWidget( - choices=models.Institution.objects.values_list( - "id", "name", "country" - ).order_by("-name") - ) - } + widgets = {"institution": widgets.InstitutionAutoCompleteWidget()} labels = {"institution": _("Institution")} @@ -102,8 +100,10 @@ def __init__(self, **kwargs) -> None: self.fields["user"].label = _("Leader") def save(self, commit: bool = ...) -> models.Participation: + super().save(commit=False) self.instance.is_leader = True - return super().save(commit=commit) + self.instance.save() + self._save_m2m() class ChangeLeaderForm(Form): diff --git a/lab/migrations/0033_institution_ror_id.py b/lab/migrations/0033_institution_ror_id.py new file mode 100644 index 000000000..727c517c4 --- /dev/null +++ b/lab/migrations/0033_institution_ror_id.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.3 on 2024-04-01 09:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("lab", "0032_alter_participation_project"), + ] + + operations = [ + migrations.AddField( + model_name="institution", + name="ror_id", + field=models.CharField( + blank=True, + help_text="Research Organization Registry ID", + max_length=255, + null=True, + verbose_name="ROR ID", + ), + ), + ] diff --git a/lab/migrations/0034_alter_institution_country_alter_institution_name.py b/lab/migrations/0034_alter_institution_country_alter_institution_name.py new file mode 100644 index 000000000..d9b8a4100 --- /dev/null +++ b/lab/migrations/0034_alter_institution_country_alter_institution_name.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.3 on 2024-04-01 09:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("lab", "0033_institution_ror_id"), + ] + + operations = [ + migrations.AlterField( + model_name="institution", + name="country", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="country" + ), + ), + migrations.AlterField( + model_name="institution", + name="name", + field=models.CharField(max_length=255, verbose_name="name"), + ), + ] diff --git a/lab/models/participation.py b/lab/models/participation.py index 7a2ce7493..875271bf2 100644 --- a/lab/models/participation.py +++ b/lab/models/participation.py @@ -5,7 +5,7 @@ from django.db.models.constraints import UniqueConstraint from django.utils.translation import gettext_lazy as _ -from shared.models import LowerCharField, TimestampedModel +from shared.models import TimestampedModel class Participation(TimestampedModel): @@ -40,11 +40,19 @@ class Meta: class Institution(models.Model): - name = LowerCharField(verbose_name=_("name"), max_length=255) - country = LowerCharField( + name = models.CharField(verbose_name=_("name"), max_length=255) + country = models.CharField( verbose_name=_("country"), max_length=255, blank=True, null=True ) + ror_id = models.CharField( + verbose_name="ROR ID", + max_length=255, + blank=True, + null=True, + help_text="Research Organization Registry ID", + ) + class Meta: constraints = [ UniqueConstraint( diff --git a/lab/static/css/admin/project-admin.css b/lab/static/css/admin/project-admin.css index 1dfcdc705..7f768dbdd 100644 --- a/lab/static/css/admin/project-admin.css +++ b/lab/static/css/admin/project-admin.css @@ -12,7 +12,7 @@ body.change-list { #participation_set-group .field-user input { max-width: unset; - width: 30rem; + width: 100%; } #participation_set-group td.delete input { @@ -24,6 +24,22 @@ body.change-list { min-width: 500px; } +#participation_set-group .typeahead-field { + width: 60%; +} + +#participation_set-group .autocomplete-input__country { + width: 40%; +} + +#participation_set-group .delete { + vertical-align: middle; +} + +#participation_set-group.inline-group { + overflow: inherit !important; /* for typeahead list to be absolute */ +} + #participation_set-group tbody th { text-transform: uppercase; font-size: 0.6875rem; diff --git a/lab/static/css/widgets/institution-autocomplete-widget.css b/lab/static/css/widgets/institution-autocomplete-widget.css new file mode 100644 index 000000000..97d923208 --- /dev/null +++ b/lab/static/css/widgets/institution-autocomplete-widget.css @@ -0,0 +1,23 @@ +.typeahead-field { + position: relative; + display: inline-block; + } + +.typeahead-field .typeahead-list { + background-color: var(--background-default-grey); + position: absolute; + z-index: 1000; + top: 41px; + width: 300px; + max-height: 500px; + overflow: scroll; + left: 0; + right: 0; +} + +.typeahead-field .typeahead-list button { + text-align: left; + padding: .75rem 1rem; + width: 100%; +} + \ No newline at end of file diff --git a/lab/static/js/widgets/institution-autocomplete-widget.js b/lab/static/js/widgets/institution-autocomplete-widget.js index a6147b2b6..dd9d19942 100644 --- a/lab/static/js/widgets/institution-autocomplete-widget.js +++ b/lab/static/js/widgets/institution-autocomplete-widget.js @@ -3,44 +3,114 @@ (function () { const baseSelector = ".autocomplete-input"; - function onNameInput(event) { + let fetchTimeoutId = null; // for debouncing when fetching from ROR API + + function onResultClicked(event, { country, name, rorId }) { + const parentField = event.target.closest(".field-institution"); + parentField.querySelector(".autocomplete-input__name").value = name; + parentField.querySelector(".autocomplete-input__country").value = country; + parentField.querySelector(".autocomplete-input__ror_id").value = rorId; + + parentField.querySelector(".typeahead-list").classList.add("hidden"); + } + + function onCountryOrNameInput(event) { + const institutionField = event.target.closest(".field-institution"); + + const idInput = institutionField.querySelector(".autocomplete-input__id"); + if (idInput.value && idInput.value !== "") { + idInput.value = ""; + } + } + + function onCountryInput(event) { + onCountryOrNameInput(event); + } + + async function onNameInput(event) { + // On institution name input, fetch institutions from ROR API const parentElement = event.target.parentElement; - const options = parentElement.querySelectorAll("datalist option"); - const index = Array.from(options).findIndex( - (o) => o.value === event.target.value, - ); - if (index === -1) { - parentElement.querySelector(`${baseSelector}__id`).value = ""; - } else { - const [name, country] = event.target.value.split(", "); - if (country) { - parentElement.querySelector(`${baseSelector}__country`).value = country; - } - parentElement.querySelector(`${baseSelector}__name`).value = name; + + onCountryOrNameInput(event); + + if (fetchTimeoutId) { + clearTimeout(fetchTimeoutId); // debounce } + + fetchTimeoutId = setTimeout(async () => { + const response = await fetch( + "https://api.ror.org/organizations?query=" + + encodeURIComponent(event.target.value), + ); + + if (!response.ok) { + return; + } + + const results = (await response.json()).items; + + const typeaheadList = parentElement.querySelector(".typeahead-list"); + + typeaheadList.classList.remove("hidden"); + + typeaheadList.querySelectorAll("button").forEach((o) => o.remove()); + results.forEach((result) => { + const buttonElement = document.createElement("button"); + buttonElement.textContent = `${result.name}, ${result.country?.country_name}`; + buttonElement.id = result.id; + buttonElement.type = "button"; + buttonElement.addEventListener("click", (event) => + onResultClicked(event, { + country: result.country?.country_name, + name: result.name, + rorId: result.id, + }), + ); + parentElement + .querySelector(".typeahead-list") + .appendChild(buttonElement); + }); + }, 500); } document.addEventListener("DOMContentLoaded", function () { + // Add event listeners to institution input elements document.querySelectorAll(`${baseSelector}`).forEach((el) => { el.querySelector(`${baseSelector}__name`).addEventListener( "input", onNameInput, ); + el.querySelector(`${baseSelector}__country`).addEventListener( + "input", + onCountryInput, + ); + }); + + document.addEventListener("click", function (event) { + // Hide typeahead list when clicking outside + document.querySelectorAll(".typeahead-list").forEach((el) => { + const isInside = el.contains(event.target); + if (!isInside && !el.classList.contains("hidden")) { + el.classList.add("hidden"); + } + }); }); setTimeout(() => { + // Add event listeners to new institution input elements document .querySelector("#participation_set-group .add-row a") .addEventListener("click", function () { - console.log("clicked"); const elements = document.querySelectorAll( ".dynamic-participation_set", ); const lastElement = elements[elements.length - 1]; - console.log(lastElement); lastElement .querySelector(`${baseSelector}__name`) .addEventListener("input", onNameInput); + lastElement + .querySelector(`${baseSelector}__country`) + .addEventListener("input", onCountryInput); }); }, 300); }); diff --git a/lab/templates/widgets/institution_autocomplete_widget.html b/lab/templates/widgets/institution_autocomplete_widget.html index b52d1588b..6403f6816 100644 --- a/lab/templates/widgets/institution_autocomplete_widget.html +++ b/lab/templates/widgets/institution_autocomplete_widget.html @@ -1,13 +1,12 @@ {% load i18n %}
- - - {% for choice in widget.choices %} - - {% endfor %} - +
+ +
+
- + +
\ No newline at end of file diff --git a/lab/tests/forms/test_base_participation_form.py b/lab/tests/forms/test_base_participation_form.py index dd9e90df0..a02b2f375 100644 --- a/lab/tests/forms/test_base_participation_form.py +++ b/lab/tests/forms/test_base_participation_form.py @@ -1,36 +1,41 @@ import pytest from lab.models import Institution +from lab.tests.factories import ParticipationFactory from ...forms import BaseParticipationForm from ...widgets import InstitutionAutoCompleteWidget @pytest.mark.django_db -def test_form_institution_widget_choices(): - Institution.objects.create(name="I2", country="france") - Institution.objects.create(name="I1", country="spain") +def test_form_institution_widget_instance_when_editing(): + participation = ParticipationFactory() - form = BaseParticipationForm() + form = BaseParticipationForm(instance=participation) assert isinstance(form.fields["institution"].widget, InstitutionAutoCompleteWidget) + assert form.fields["institution"].widget.instance == participation.institution + - choices = list(form.fields["institution"].widget.choices) - assert len(choices) == 3 - assert choices[0] == ("", "---------") - assert choices[1][1] == ("i2, france") - assert choices[2][1] == ("i1, spain") +def test_form_institution_widget_instance_when_creating(): + form = BaseParticipationForm() + + assert isinstance(form.fields["institution"].widget, InstitutionAutoCompleteWidget) + assert form.fields["institution"].widget.instance is None @pytest.mark.django_db def test_try_populate_institution_find_institution(): - institution = Institution.objects.create(name="institution", country="france") + institution = Institution.objects.create( + name="institution", country="france", ror_id="test" + ) form = BaseParticipationForm() form.prefix = prefix = "inst" form.data = { f"{prefix}-institution__name": "institution", f"{prefix}-institution__country": "france", + f"{prefix}-institution__ror_id": "test", } form.try_populate_institution() @@ -42,6 +47,7 @@ def test_try_populate_institution_find_institution(): def test_try_populate_institution_create_institution(): name = "some_institution" country = "azerty" + ror_id = "test" form = BaseParticipationForm() form.prefix = prefix = "inst" @@ -49,9 +55,10 @@ def test_try_populate_institution_create_institution(): form.data = { f"{prefix}-institution__name": name, f"{prefix}-institution__country": country, + f"{prefix}-institution__ror_id": ror_id, } form.try_populate_institution() - institution = Institution.objects.get(name=name, country=country) + institution = Institution.objects.get(name=name, country=country, ror_id=ror_id) assert form.data[f"{prefix}-institution"] == institution.pk diff --git a/lab/tests/widgets/__init__.py b/lab/tests/widgets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lab/tests/widgets/test_institution_auto_complete_widget.py b/lab/tests/widgets/test_institution_auto_complete_widget.py new file mode 100644 index 000000000..18553e11d --- /dev/null +++ b/lab/tests/widgets/test_institution_auto_complete_widget.py @@ -0,0 +1,26 @@ +import pytest + +from ...models.participation import Institution +from ...widgets import InstitutionAutoCompleteWidget + + +def test_context_when_instance_is_none(): + widget = InstitutionAutoCompleteWidget() + context = widget.get_context("name", None, {"attrs": "attrs"}) + assert context["widget"]["instance"] is None + + +def test_context_when_instance_is_not_none(): + institution = Institution() + widget = InstitutionAutoCompleteWidget() + widget.instance = institution + context = widget.get_context("name", None, {"attrs": "attrs"}) + assert context["widget"]["instance"] == institution + + +@pytest.mark.django_db +def test_context_when_value(): + institution = Institution.objects.create(name="I", country="france") + widget = InstitutionAutoCompleteWidget() + context = widget.get_context("name", institution.pk, {"attrs": "attrs"}) + assert context["widget"]["instance"] == institution diff --git a/lab/widgets.py b/lab/widgets.py index 61bbaa41d..ffa426b10 100644 --- a/lab/widgets.py +++ b/lab/widgets.py @@ -14,6 +14,8 @@ ) from django.urls import reverse +from lab.models.participation import Institution + class UserWidgetWrapper(RelatedFieldWidgetWrapper): # pylint: disable=too-many-arguments @@ -46,9 +48,7 @@ class InstitutionAutoCompleteWidget(Widget): input_type = "text" template_name = "widgets/institution_autocomplete_widget.html" - def __init__(self, attrs: dict[str, Any] | None = None, choices=None) -> None: - self.choices = choices - super().__init__(attrs) + instance: Institution | None = None def get_context( self, name: str, value: Any, attrs: dict[str, Any] | None @@ -56,19 +56,17 @@ def get_context( attrs = attrs or {} attrs["class"] = "fr-input" context = super().get_context(name, value, attrs) - context["widget"]["choices"] = self.choices - context["widget"]["instance"] = next( - ( - choice[0].instance - for choice in list(self.choices) - if choice[0] and choice[0].instance.id == value - ), - None, - ) + if self.instance: + context["widget"]["instance"] = self.instance + elif value: + context["widget"]["instance"] = Institution.objects.get(pk=value) + else: + context["widget"]["instance"] = None return context class Media: js = ("js/widgets/institution-autocomplete-widget.js",) + css = {"all": ("css/widgets/institution-autocomplete-widget.css",)} class InstitutionWidgetWrapper(RelatedFieldWidgetWrapper):