diff --git a/.greenmask/config.yml b/.greenmask/config.yml index 58597f25..a7c2e9e3 100644 --- a/.greenmask/config.yml +++ b/.greenmask/config.yml @@ -24,6 +24,7 @@ validate: - "d07adfc9703aa9e74d70a80f8ee810ef" # ingest_lesion.private_lesion_id - "5332871852b79c027b147766489a58fd" # login_profile.hash_id - "d7f018cfb8bdf6841722925f5312e726" # studies_study.name + - "c3a91674ddb132e2940842f3516df5a0" # engagement_emaildomaincontributor.domain # "transformer may produce NULL values but column has NOT NULL constraint": # Replace writes a non-NULL constant and the source columns are themselves # NOT NULL, so no NULL can actually be produced. @@ -141,6 +142,17 @@ dump: column: "default_attribution" template: '{{ fakerWord | title }} {{ fakerWord | title }} Institute' + # ============ ENGAGEMENT EMAIL DOMAINS ============ + # A real mail domain names the institution ingest_contributor anonymizes, so + # hash it the same way as account_emailaddress.email. + - schema: "public" + name: "engagement_emaildomaincontributor" + transformers: + - name: "Template" + params: + column: "domain" + template: 'institution{{ substr 0 12 (sha256sum (printf "%s:email_domain:%s" (env "SALT") .GetValue)) }}.test' + # ============ PRIVATE IDs (Patient / Lesion / RcmCase) ============ - schema: "public" name: "ingest_patient" diff --git a/isic/engagement/forms.py b/isic/engagement/forms.py index 640af7f6..10d67ab0 100644 --- a/isic/engagement/forms.py +++ b/isic/engagement/forms.py @@ -1,10 +1,12 @@ from django import forms +from isic.core.models.base import CopyrightLicense from isic.engagement.models import ( EMAIL_DOMAIN_ERROR, EMAIL_DOMAIN_PATTERN, EmailDomainContributor, ) +from isic.ingest.models import Cohort, Contributor class EmailDomainContributorForm(forms.ModelForm): @@ -26,3 +28,132 @@ class Meta: help_texts = { "contributor": "The contributor users with this email domain belong to.", } + + +class AssignExistingDefaultsForm(forms.Form): + """Pick an existing contributor, and optionally one of its cohorts, as a user's defaults.""" + + contributor = forms.ModelChoiceField( + widget=forms.HiddenInput(), + queryset=Contributor.objects.all(), + required=True, + label="Contributor", + help_text="Uploads from this user will be attributed to this contributor.", + ) + # rendered by hand as a + + + + +

{{ field.help_text|safe }}

+ {{ field.errors }} + diff --git a/isic/engagement/templates/engagement/user_assign.html b/isic/engagement/templates/engagement/user_assign.html new file mode 100644 index 00000000..c491e89f --- /dev/null +++ b/isic/engagement/templates/engagement/user_assign.html @@ -0,0 +1,92 @@ +{% extends 'core/base.html' %} +{% load static %} + +{% block head_extra %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+
Assign Engagement Defaults
+

+ Set the contributor and cohort this user's uploads will be attributed to. +

+
+ +
+
+
+ {{ engagement_profile.user.first_name }} {{ engagement_profile.user.last_name }} +
+
{{ engagement_profile.user.email }}
+
+ {% if email_domain %} +
+
Email domain
+
{{ email_domain }}
+
+ {% endif %} +
+ + {% if suggested_contributor %} +
+ +
+ {# any of the user's verified addresses can produce the match, not just the primary one #} +
Suggested from a verified email domain
+ {% if suggested_cohort %} +
+ Contributor {{ suggested_contributor.institution_name }} and its only + cohort {{ suggested_cohort.name }}, prefilled below. +
+ {% elif initial_cohorts %} +
+ Contributor {{ suggested_contributor.institution_name }}, prefilled + below. It has several cohorts, so pick the right one. +
+ {% else %} +
+ Contributor {{ suggested_contributor.institution_name }}, prefilled + below. It has no cohorts yet, so assign the contributor alone for now. +
+ {% endif %} +
Change it if it isn't right.
+
+
+ {% endif %} + +
+ +
+ Assigning this user makes them an owner of the contributor, granting access + to all existing and unpublished images in that contributor's cohorts. Only proceed if + they're authorized for this institution's data. +
+
+ + {% comment %} + Radio driven tabs, matching the study/user/image detail pages. The server picks the open tab + by checking one of the radios, so a submission that fails validation comes back to the tab it + was made from without any client side state. + {% endcomment %} +
+ +
+
+ {% include 'engagement/partials/assign_existing_form.html' with form=existing_form %} +
+
+ + +
+
+ {% include 'engagement/partials/assign_create_form.html' with form=create_form %} +
+
+
+
+{% endblock %} diff --git a/isic/engagement/templates/engagement/user_list.html b/isic/engagement/templates/engagement/user_list.html index a4a3fdc4..b382eb47 100644 --- a/isic/engagement/templates/engagement/user_list.html +++ b/isic/engagement/templates/engagement/user_list.html @@ -4,48 +4,58 @@ {% block content %}
-
Engagement Users
-
- Every user who has authenticated through the engagement platform. Their default contributor - and cohort determine where their uploads flow into the Archive. -
-
- Total: {{ profiles|length|intcomma }} users -
+
Engagement Users ({{ page.paginator.count|intcomma }})
+

+ Every user who has authenticated through the engagement platform. Assign each one a default + contributor and cohort so their uploads can flow into the Archive. +

- {% if profiles %} +
+ +
+ + {% if page.object_list %}
- - + + + - {% for profile in profiles %} + {% for profile, suggested_contributor in rows %} - + + {% endfor %}
UserCreatedLast LoginStatus Default Contributor Default CohortLast Login
{{ profile.user.first_name }} {{ profile.user.last_name }}
{{ profile.user.email }}
+
joined {{ profile.created|timesince }} ago
-
{{ profile.created|date:"M j, Y" }}
-
{{ profile.created|timesince }} ago
-
- {% if profile.user.last_login %} -
{{ profile.user.last_login|date:"M j, Y" }}
-
{{ profile.user.last_login|timesince }} ago
+ {% if profile.default_cohort %} + Assigned + {% elif profile.default_contributor %} + Needs cohort + {% elif suggested_contributor %} + Suggestion ready {% else %} - + Needs assignment {% endif %}
@@ -64,16 +74,38 @@ {% endif %} + {% if profile.user.last_login %} +
{{ profile.user.last_login|date:"M j, Y" }}
+
{{ profile.user.last_login|timesince }} ago
+ {% else %} + + {% endif %} +
+ + {% if profile.default_cohort %}Reassign{% else %}Assign{% endif %} + +
+ +
+ {% include 'core/partials/pagination_widget.html' with page_obj=page %} +
{% else %}
-

No engagement users yet

-

Users appear here once they authenticate through the engagement platform.

+ {% if only_unassigned %} +

No engagement users need assignment

+

Every engagement user has both a default contributor and cohort. Uncheck the filter above to see them.

+ {% else %} +

No engagement users yet

+

Users appear here once they authenticate through the engagement platform.

+ {% endif %}
{% endif %} diff --git a/isic/engagement/tests/factories.py b/isic/engagement/tests/factories.py index ad90d674..0c4151dc 100644 --- a/isic/engagement/tests/factories.py +++ b/isic/engagement/tests/factories.py @@ -15,7 +15,7 @@ class Meta: default_cohort = None class Params: - # A profile that has been through the provenance form, with a contributor/cohort + # A profile that has been through the engagement defaults form, with a contributor/cohort # pair that is internally consistent. provisioned = factory.Trait( default_cohort=factory.SubFactory(CohortFactory), diff --git a/isic/engagement/tests/test_email_domain.py b/isic/engagement/tests/test_email_domain.py index 53c10413..f57f7238 100644 --- a/isic/engagement/tests/test_email_domain.py +++ b/isic/engagement/tests/test_email_domain.py @@ -4,10 +4,7 @@ from isic.engagement.forms import EmailDomainContributorForm from isic.engagement.models import EMAIL_DOMAIN_ERROR, EmailDomainContributor -from isic.engagement.services.email_domain import ( - suggest_contributor_for_email, - suggest_contributor_for_user, -) +from isic.engagement.services.email_domain import suggest_contributor_for_users @pytest.mark.django_db @@ -127,25 +124,13 @@ def test_email_domain_delete(staff_client, email_domain_contributor): @pytest.mark.django_db -def test_suggest_contributor_for_email(email_domain_contributor): - domain = email_domain_contributor.domain - contributor = email_domain_contributor.contributor - - assert suggest_contributor_for_email(f"someone@{domain}") == contributor - assert suggest_contributor_for_email(f"Someone+Tagged@{domain.upper()}") == contributor - assert suggest_contributor_for_email(f"someone@other-{domain}") is None - assert suggest_contributor_for_email("") is None - assert suggest_contributor_for_email("someone@") is None - # a bare domain isn't an email address - assert suggest_contributor_for_email(domain) is None - - -@pytest.mark.django_db -def test_suggest_contributor_for_user( - user, email_address_factory, email_domain_contributor_factory +def test_suggest_contributor_for_users( + user, user_factory, email_address_factory, email_domain_contributor_factory ): - # the address the user was created with maps to nothing - assert suggest_contributor_for_user(user) is None + other_user = user_factory() + + # the addresses the users were created with map to nothing + assert suggest_contributor_for_users([user, other_user]) == {} unverified_match = email_domain_contributor_factory() email_address_factory( @@ -155,7 +140,7 @@ def test_suggest_contributor_for_user( primary=False, ) # unverified addresses are self-asserted, so they don't produce a suggestion - assert suggest_contributor_for_user(user) is None + assert suggest_contributor_for_users([user]) == {} secondary_match = email_domain_contributor_factory() email_address_factory( @@ -165,9 +150,22 @@ def test_suggest_contributor_for_user( primary=False, ) # every verified address is considered, not just the primary one - assert suggest_contributor_for_user(user) == secondary_match.contributor + assert suggest_contributor_for_users([user]) == {user.pk: secondary_match.contributor} primary_match = email_domain_contributor_factory() user.emailaddress_set.filter(primary=True).update(email=f"someone@{primary_match.domain}") # the primary address wins when several addresses map to different contributors - assert suggest_contributor_for_user(user) == primary_match.contributor + assert suggest_contributor_for_users([user]) == {user.pk: primary_match.contributor} + + other_match = email_domain_contributor_factory() + email_address_factory( + user=other_user, + email=f"Someone+Tagged@{other_match.domain.upper()}", + verified=True, + primary=False, + ) + # domains are matched case insensitively, and every user is resolved in one pass + assert suggest_contributor_for_users([user, other_user]) == { + user.pk: primary_match.contributor, + other_user.pk: other_match.contributor, + } diff --git a/isic/engagement/tests/test_user_assign.py b/isic/engagement/tests/test_user_assign.py new file mode 100644 index 00000000..e5bee180 --- /dev/null +++ b/isic/engagement/tests/test_user_assign.py @@ -0,0 +1,157 @@ +from django.urls.base import reverse +import pytest +from pytest_lazy_fixtures import lf + +from isic.ingest.models import Cohort, Contributor + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("client_", "expected_status"), + [ + (lf("client"), 302), + (lf("authenticated_client"), 302), + (lf("staff_client"), 200), + ], + ids=["anonymous", "authenticated", "staff"], +) +def test_engagement_user_assign_permissions(client_, expected_status, engagement_profile): + r = client_.get(reverse("engagement/user-assign", args=[engagement_profile.pk])) + assert r.status_code == expected_status + + +@pytest.mark.django_db +def test_engagement_user_assign_existing(staff_client, engagement_profile, cohort, cohort_factory): + other_cohort = cohort_factory() + url = reverse("engagement/user-assign", args=[engagement_profile.pk]) + + # a contributor with no cohort is a valid half provisioned state + r = staff_client.post( + url, data={"action": "select_existing", "contributor": cohort.contributor.pk} + ) + + assert r.status_code == 302 + assert r.url == reverse("engagement/user-list") + engagement_profile.refresh_from_db() + assert engagement_profile.default_contributor == cohort.contributor + assert engagement_profile.default_cohort is None + # assignment is what makes the user able to upload into the contributor's cohorts + assert cohort.contributor.owners.contains(engagement_profile.user) + + # a cohort belonging to a different contributor is rejected and changes nothing + r = staff_client.post( + url, + data={ + "action": "select_existing", + "contributor": cohort.contributor.pk, + "cohort": other_cohort.pk, + }, + ) + + assert r.status_code == 200 + assert "belongs to" in r.content.decode() + engagement_profile.refresh_from_db() + assert engagement_profile.default_cohort is None + assert not other_cohort.contributor.owners.contains(engagement_profile.user) + + r = staff_client.post( + url, + data={ + "action": "select_existing", + "contributor": cohort.contributor.pk, + "cohort": cohort.pk, + }, + ) + + assert r.status_code == 302 + engagement_profile.refresh_from_db() + assert engagement_profile.default_cohort == cohort + + +@pytest.mark.django_db +def test_engagement_user_assign_create( + staff_client, engagement_profile, contributor_factory, cohort_factory +): + # built, not created, so the payload carries generated values without persisting anything + new_contributor = contributor_factory.build() + new_cohort = cohort_factory.build(contributor=None, creator=None) + url = reverse("engagement/user-assign", args=[engagement_profile.pk]) + + r = staff_client.post( + url, + data={ + "action": "create_new", + "institution_name": new_contributor.institution_name, + "legal_contact_info": new_contributor.legal_contact_info, + "default_attribution": new_cohort.default_attribution, + "cohort_name": new_cohort.name, + "cohort_description": new_cohort.description, + "cohort_default_copyright_license": new_cohort.default_copyright_license, + }, + ) + + assert r.status_code == 302, r.context["create_form"].errors + assert r.url == reverse("engagement/user-list") + + engagement_profile.refresh_from_db() + contributor = Contributor.objects.get() + cohort = Cohort.objects.get() + + assert contributor.institution_name == new_contributor.institution_name + assert contributor.legal_contact_info == new_contributor.legal_contact_info + assert cohort.contributor == contributor + assert cohort.name == new_cohort.name + assert cohort.default_copyright_license == new_cohort.default_copyright_license + # the form only asks for attribution once, at the institution level + assert contributor.default_attribution == new_cohort.default_attribution + assert cohort.default_attribution == contributor.default_attribution + assert engagement_profile.default_contributor == contributor + assert engagement_profile.default_cohort == cohort + assert contributor.owners.contains(engagement_profile.user) + + # an incomplete submission creates nothing and reopens the tab it was submitted from + r = staff_client.post(url, data={"action": "create_new", "institution_name": "Some Institute"}) + + assert r.status_code == 200 + assert r.context["mode"] == "create_new" + assert Contributor.objects.count() == 1 + assert Cohort.objects.count() == 1 + + +@pytest.mark.django_db +def test_engagement_user_assign_prefills_suggestion( + staff_client, engagement_profile, email_domain_contributor, cohort_factory +): + engagement_profile.user.emailaddress_set.filter(primary=True).update( + email=f"someone@{email_domain_contributor.domain}" + ) + url = reverse("engagement/user-assign", args=[engagement_profile.pk]) + + r = staff_client.get(url) + + assert r.status_code == 200 + assert r.context["suggested_contributor"] == email_domain_contributor.contributor + assert r.context["existing_form"].initial["contributor"] == email_domain_contributor.contributor + # the contributor has no cohorts yet, so there's nothing to suggest or select + assert r.context["existing_form"].initial["cohort"] is None + assert r.context["initial_cohorts"] == [] + + cohort = cohort_factory(contributor=email_domain_contributor.contributor) + # a cohort under someone else must never reach the select + cohort_factory() + r = staff_client.get(url) + + assert r.context["existing_form"].initial["cohort"] == cohort + assert r.context["initial_cohorts"] == [{"id": cohort.pk, "name": cohort.name}] + + # a second cohort makes the choice ambiguous, so no cohort is suggested, but both are + # still offered in the select + second_cohort = cohort_factory(contributor=email_domain_contributor.contributor) + r = staff_client.get(url) + + assert r.context["suggested_contributor"] == email_domain_contributor.contributor + assert r.context["existing_form"].initial["cohort"] is None + assert {option["id"] for option in r.context["initial_cohorts"]} == { + cohort.pk, + second_cohort.pk, + } diff --git a/isic/engagement/tests/test_user_assign_browser.py b/isic/engagement/tests/test_user_assign_browser.py new file mode 100644 index 00000000..0dee8cf7 --- /dev/null +++ b/isic/engagement/tests/test_user_assign_browser.py @@ -0,0 +1,147 @@ +from django.urls import reverse +from playwright.sync_api import expect +import pytest + +from isic.ingest.models import Contributor + + +@pytest.mark.playwright +def test_engagement_user_assign( + staff_authenticated_page, engagement_profile_factory, cohort, cohort_factory +): + page = staff_authenticated_page + engagement_profile = engagement_profile_factory() + contributor = cohort.contributor + # belongs to a different contributor, so it must never appear in the cohort select + other_cohort = cohort_factory() + + page.goto(reverse("engagement/user-list")) + + expect(page.get_by_text("Needs assignment")).to_be_visible() + page.get_by_role("link", name="Assign").click() + + expect(page.get_by_text(engagement_profile.user.email)).to_be_visible() + expect(page.get_by_text("owner", exact=False).first).to_be_visible() + + # the create tab is rendered up front rather than fetched, so switching reveals its fields + page.get_by_role("tab", name="Create new").click() + expect(page.get_by_label("Institution Name")).to_be_visible() + page.get_by_role("tab", name="Select existing").click() + expect(page.get_by_label("Institution Name")).not_to_be_visible() + + # until a contributor is picked there's nothing to choose from + cohort_select = page.get_by_label("Cohort", exact=True) + expect(cohort_select).to_have_value("") + expect(cohort_select.locator("option")).to_have_count(1) + + contributor_fieldset = page.get_by_role("group").filter( + has=page.get_by_text("Contributor", exact=True) + ) + contributor_fieldset.get_by_role("searchbox").press_sequentially( + contributor.institution_name[:5], delay=50 + ) + contributor_fieldset.get_by_text(contributor.institution_name, exact=True).first.click() + # the preview panel confirms the selection resolved to a real contributor + expect(contributor_fieldset.get_by_text(contributor.institution_url).first).to_be_visible() + + # picking a contributor narrows the cohort select to that contributor's cohorts + expect(cohort_select.locator("option")).to_have_text(["No cohort", cohort.name]) + expect(cohort_select.locator("option", has_text=other_cohort.name)).to_have_count(0) + cohort_select.select_option(label=cohort.name) + + page.get_by_role("button", name="Save assignment").click() + + expect( + page.get_by_text(f"Assigned defaults for {engagement_profile.user.email}") + ).to_be_visible() + + engagement_profile.refresh_from_db() + assert engagement_profile.default_contributor == contributor + assert engagement_profile.default_cohort == cohort + assert contributor.owners.contains(engagement_profile.user) + + # with the filter on by default the newly assigned user drops off the list + expect(page.get_by_text("No engagement users need assignment")).to_be_visible() + + page.get_by_text("Only show users needing assignment").click() + + expect(page.get_by_role("cell", name=contributor.institution_name)).to_be_visible() + expect(page.get_by_role("link", name=cohort.name)).to_be_visible() + + +@pytest.mark.playwright +def test_engagement_user_assign_create_requires_confirmation( + staff_authenticated_page, engagement_profile_factory, contributor_factory, cohort_factory +): + """Creating a contributor and cohort must not be possible without confirming both.""" + page = staff_authenticated_page + engagement_profile = engagement_profile_factory() + # built, not created, so the form is filled with generated values that don't yet exist + new_contributor = contributor_factory.build() + new_cohort = cohort_factory.build(contributor=None, creator=None) + + page.goto(reverse("engagement/user-assign", args=[engagement_profile.pk])) + page.get_by_role("tab", name="Create new").click() + + expect(page.get_by_text("new contributor", exact=False).first).to_be_visible() + + page.get_by_label("Institution Name").fill(new_contributor.institution_name) + page.get_by_label("Legal Contact Information").fill(new_contributor.legal_contact_info) + page.get_by_label("Default Attribution").fill(new_cohort.default_attribution) + page.get_by_label("Cohort Name").fill(new_cohort.name) + page.get_by_label("Cohort Description").fill(new_cohort.description) + page.get_by_label("Default Copyright License").select_option( + new_cohort.default_copyright_license + ) + + page.get_by_role("button", name="Create and assign").click() + + # the confirmation names both objects, and backing out of it creates neither + confirm = page.get_by_role("dialog") + expect(confirm.get_by_text(new_contributor.institution_name)).to_be_visible() + expect(confirm.get_by_text(new_cohort.name)).to_be_visible() + confirm.get_by_role("button", name="Cancel").click() + + assert not Contributor.objects.filter( + institution_name=new_contributor.institution_name + ).exists() + + page.get_by_role("button", name="Create and assign").click() + page.get_by_role("button", name="Yes, create both").click() + + expect( + page.get_by_text(f"Assigned defaults for {engagement_profile.user.email}") + ).to_be_visible() + + engagement_profile.refresh_from_db() + contributor = Contributor.objects.get(institution_name=new_contributor.institution_name) + assert engagement_profile.default_contributor == contributor + assert engagement_profile.default_cohort == contributor.cohorts.get(name=new_cohort.name) + + +@pytest.mark.playwright +def test_engagement_user_assign_accepts_suggestion_unchanged( + staff_authenticated_page, engagement_profile_factory, email_domain_contributor, cohort_factory +): + """A suggested contributor and cohort must survive submission without being touched.""" + page = staff_authenticated_page + engagement_profile = engagement_profile_factory() + engagement_profile.user.emailaddress_set.filter(primary=True).update( + email=f"someone@{email_domain_contributor.domain}" + ) + cohort = cohort_factory(contributor=email_domain_contributor.contributor) + + page.goto(reverse("engagement/user-assign", args=[engagement_profile.pk])) + + # the sole cohort is preselected, not merely present among the options + expect(page.get_by_label("Cohort", exact=True)).to_have_value(str(cohort.pk)) + + page.get_by_role("button", name="Save assignment").click() + + expect( + page.get_by_text(f"Assigned defaults for {engagement_profile.user.email}") + ).to_be_visible() + + engagement_profile.refresh_from_db() + assert engagement_profile.default_contributor == email_domain_contributor.contributor + assert engagement_profile.default_cohort == cohort diff --git a/isic/engagement/tests/test_user_list.py b/isic/engagement/tests/test_user_list.py index ed450849..19f3229b 100644 --- a/isic/engagement/tests/test_user_list.py +++ b/isic/engagement/tests/test_user_list.py @@ -4,20 +4,53 @@ @pytest.mark.django_db -def test_engagement_user_list(staff_client, engagement_profile_factory): +def test_engagement_user_list( + staff_client, engagement_profile_factory, email_domain_contributor_factory, contributor +): provisioned_profile = engagement_profile_factory(provisioned=True) bare_profile = engagement_profile_factory() + # a contributor without a cohort is only half provisioned, so it still needs assignment + cohortless_profile = engagement_profile_factory(default_contributor=contributor) + suggestable_profile = engagement_profile_factory() + email_domain_contributor = email_domain_contributor_factory() + suggestable_profile.user.emailaddress_set.filter(primary=True).update( + email=f"someone@{email_domain_contributor.domain}" + ) + # the filter defaults on, so only the users still needing provisioning are listed r = staff_client.get(reverse("engagement/user-list")) assert r.status_code == 200 - profile_pks = {profile.pk for profile in r.context["profiles"]} - assert profile_pks == {provisioned_profile.pk, bare_profile.pk} + suggestions_by_pk = {profile.pk: suggestion for profile, suggestion in r.context["rows"]} + assert set(suggestions_by_pk) == { + bare_profile.pk, + cohortless_profile.pk, + suggestable_profile.pk, + } + assert suggestions_by_pk[suggestable_profile.pk] == email_domain_contributor.contributor + assert suggestions_by_pk[bare_profile.pk] is None + + content = r.content.decode() + assert "Suggestion ready" in content + assert "Needs assignment" in content + assert "Needs cohort" in content + assert bare_profile.user.email in content + + # turning it off reveals the users that have already been assigned + r = staff_client.get(reverse("engagement/user-list"), {"only_unassigned": "0"}) + assert r.status_code == 200 + + profile_pks = {profile.pk for profile in r.context["page"]} + assert profile_pks == { + provisioned_profile.pk, + bare_profile.pk, + cohortless_profile.pk, + suggestable_profile.pk, + } content = r.content.decode() assert provisioned_profile.default_cohort.name in content assert provisioned_profile.default_contributor.institution_name in content - assert bare_profile.user.email in content @pytest.mark.django_db diff --git a/isic/engagement/urls.py b/isic/engagement/urls.py index a2978ca1..6b4b2b4c 100644 --- a/isic/engagement/urls.py +++ b/isic/engagement/urls.py @@ -5,7 +5,7 @@ email_domain_edit, email_domain_list, ) -from isic.engagement.views.user import engagement_user_list +from isic.engagement.views.user import engagement_user_assign, engagement_user_list urlpatterns = [ # Staff pages @@ -25,4 +25,9 @@ name="engagement/email-domain-delete", ), path("staff/engagement/users/", engagement_user_list, name="engagement/user-list"), + path( + "staff/engagement/users//assign/", + engagement_user_assign, + name="engagement/user-assign", + ), ] diff --git a/isic/engagement/views/user.py b/isic/engagement/views/user.py index e8b0b01a..906894ed 100644 --- a/isic/engagement/views/user.py +++ b/isic/engagement/views/user.py @@ -1,13 +1,174 @@ +from django.contrib import messages from django.contrib.admin.views.decorators import staff_member_required -from django.shortcuts import render +from django.core.exceptions import ValidationError +from django.core.paginator import Paginator +from django.db import transaction +from django.db.models import Q +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.urls.base import reverse +from isic.engagement.forms import AssignExistingDefaultsForm, CondensedContributorCohortForm from isic.engagement.models import EngagementProfile +from isic.engagement.services.email_domain import ( + suggest_cohort_for_contributor, + suggest_contributor_for_users, +) +from isic.engagement.services.profile import assign_engagement_defaults +from isic.ingest.services.cohort import create_cohort +from isic.ingest.services.contributor import create_contributor @staff_member_required def engagement_user_list(request): + only_unassigned = request.GET.get("only_unassigned", "1") == "1" + profiles = EngagementProfile.objects.select_related( "user", "default_contributor", "default_cohort" ).order_by("-created") - return render(request, "engagement/user_list.html", {"profiles": profiles}) + if only_unassigned: + # a user isn't fully provisioned until they have both, and a contributor without a cohort + # is a common half finished state since the cohort can be left for later. + profiles = profiles.filter( + Q(default_contributor__isnull=True) | Q(default_cohort__isnull=True) + ) + + paginator = Paginator(profiles, 50) + page = paginator.get_page(request.GET.get("page")) + + suggestions = suggest_contributor_for_users([profile.user for profile in page]) + rows = [(profile, suggestions.get(profile.user_id)) for profile in page] + + return render( + request, + "engagement/user_list.html", + {"page": page, "rows": rows, "only_unassigned": only_unassigned}, + ) + + +def _assign_existing(request, engagement_profile: EngagementProfile) -> AssignExistingDefaultsForm: + form = AssignExistingDefaultsForm(request.POST) + + if form.is_valid(): + try: + assign_engagement_defaults( + engagement_profile=engagement_profile, + contributor=form.cleaned_data["contributor"], + cohort=form.cleaned_data["cohort"], + ) + except ValidationError as e: + # the messages come from models whose field names don't line up with this form's, + # so they're surfaced as non-field errors rather than reattached to fields. + form.add_error(None, e.messages) + + return form + + +def _assign_created( + request, engagement_profile: EngagementProfile +) -> CondensedContributorCohortForm: + form = CondensedContributorCohortForm(request.POST) + + if form.is_valid(): + license_ = form.cleaned_data["cohort_default_copyright_license"] + try: + with transaction.atomic(): + # the engagement user is the creator rather than the staff member doing the + # provisioning, since the contributor is being stood up on their behalf and + # they're the one who has to own it to upload into it. + contributor = create_contributor( + creator=engagement_profile.user, + institution_name=form.cleaned_data["institution_name"], + legal_contact_info=form.cleaned_data["legal_contact_info"], + default_attribution=form.cleaned_data["default_attribution"], + default_copyright_license=license_, + ) + cohort = create_cohort( + creator=engagement_profile.user, + contributor=contributor, + name=form.cleaned_data["cohort_name"], + description=form.cleaned_data["cohort_description"], + default_copyright_license=license_, + # attribution is only asked for once, at the institution level, since the + # cohort being created is that institution's first. + default_attribution=contributor.default_attribution, + ) + assign_engagement_defaults( + engagement_profile=engagement_profile, + contributor=contributor, + cohort=cohort, + ) + except ValidationError as e: + # the messages come from models whose field names don't line up with this form's, + # so they're surfaced as non-field errors rather than reattached to fields. + form.add_error(None, e.messages) + + return form + + +@staff_member_required +def engagement_user_assign(request, pk): + engagement_profile = get_object_or_404( + EngagementProfile.objects.select_related("user", "default_contributor", "default_cohort"), + pk=pk, + ) + suggested_contributor = suggest_contributor_for_users([engagement_profile.user]).get( + engagement_profile.user_id + ) + suggested_cohort = ( + suggest_cohort_for_contributor(suggested_contributor) if suggested_contributor else None + ) + + # which tab was submitted, and therefore which tab to reopen if it fails validation + mode = "create_new" if request.POST.get("action") == "create_new" else "select_existing" + initial_contributor = engagement_profile.default_contributor or suggested_contributor + existing_form = AssignExistingDefaultsForm( + initial={ + "contributor": initial_contributor, + "cohort": engagement_profile.default_cohort or suggested_cohort, + } + ) + create_form = CondensedContributorCohortForm() + + if request.method == "POST": + if mode == "create_new": + create_form = _assign_created(request, engagement_profile) + succeeded = create_form.is_valid() + else: + existing_form = _assign_existing(request, engagement_profile) + succeeded = existing_form.is_valid() + + if succeeded: + messages.success(request, f"Assigned defaults for {engagement_profile.user.email}.") + return HttpResponseRedirect(reverse("engagement/user-list")) + + _, _, email_domain = engagement_profile.user.email.rpartition("@") + + # the cohort select is narrowed to one contributor's cohorts. seeding the options for the + # contributor the page opens with means the initial selection is present on first paint, + # rather than appearing only once the contributor autocomplete's fetch resolves. + contributor_for_cohorts = ( + existing_form.cleaned_data.get("contributor") + if existing_form.is_bound + else initial_contributor + ) + + return render( + request, + "engagement/user_assign.html", + { + "engagement_profile": engagement_profile, + "email_domain": email_domain, + "existing_form": existing_form, + "create_form": create_form, + "mode": mode, + "suggested_contributor": suggested_contributor, + "suggested_cohort": suggested_cohort, + "initial_cohorts": ( + list(contributor_for_cohorts.cohorts.order_by("name").values("id", "name")) + if contributor_for_cohorts + else [] + ), + }, + ) diff --git a/isic/ingest/services/cohort/__init__.py b/isic/ingest/services/cohort/__init__.py index 382403fc..650bff29 100644 --- a/isic/ingest/services/cohort/__init__.py +++ b/isic/ingest/services/cohort/__init__.py @@ -1,5 +1,6 @@ import logging +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Count @@ -7,6 +8,7 @@ from isic.core.services.collection import merge_magic_collections from isic.ingest.models.accession import Accession from isic.ingest.models.cohort import Cohort +from isic.ingest.models.contributor import Contributor from isic.ingest.models.metadata_file import MetadataFile from isic.ingest.models.metadata_version import MetadataVersion from isic.ingest.models.zip_upload import ZipUpload @@ -14,6 +16,30 @@ logger = logging.getLogger(__name__) +def create_cohort( # noqa: PLR0913 + *, + creator: User, + contributor: Contributor, + name: str, + description: str, + default_copyright_license: str, + default_attribution: str, +) -> Cohort: + with transaction.atomic(): + cohort = Cohort( + creator=creator, + contributor=contributor, + name=name, + description=description, + default_copyright_license=default_copyright_license, + default_attribution=default_attribution, + ) + # the magic collection is only minted at publish time, and the field isn't blank=True + cohort.full_clean(exclude=["collection"]) + cohort.save() + return cohort + + def delete_cohort(*, cohort: Cohort) -> None: # This check also guarantees the cohort won't point to a collection. if cohort.accessions.published().exists(): diff --git a/isic/ingest/services/contributor/__init__.py b/isic/ingest/services/contributor/__init__.py index d566ce44..c505c855 100644 --- a/isic/ingest/services/contributor/__init__.py +++ b/isic/ingest/services/contributor/__init__.py @@ -1,8 +1,33 @@ +from django.contrib.auth.models import User from django.db import transaction from isic.ingest.models.contributor import Contributor +def create_contributor( # noqa: PLR0913 + *, + creator: User, + institution_name: str, + legal_contact_info: str, + institution_url: str = "", + default_copyright_license: str = "", + default_attribution: str = "", +) -> Contributor: + with transaction.atomic(): + contributor = Contributor( + creator=creator, + institution_name=institution_name, + legal_contact_info=legal_contact_info, + institution_url=institution_url, + default_copyright_license=default_copyright_license, + default_attribution=default_attribution, + ) + contributor.full_clean() + contributor.save() + contributor.owners.add(creator) + return contributor + + def merge_contributors(*, dest_contributor: Contributor, src_contributor: Contributor) -> None: """Merge a src_contributor into dest_contributor.""" with transaction.atomic(): diff --git a/isic/ingest/static/ingest/autocomplete.js b/isic/ingest/static/ingest/autocomplete.js index 1c17b583..5021f282 100644 --- a/isic/ingest/static/ingest/autocomplete.js +++ b/isic/ingest/static/ingest/autocomplete.js @@ -18,6 +18,10 @@ function autocompleteInput({ suggestUrl, detailUrl, labelKey = 'name', required }, async init() { + this.$watch('selectedDetail', (selection) => + this.$dispatch('autocomplete-selected', { selection }), + ); + this.selectedId = this.$refs.hiddenInput.defaultValue; if (this.selectedId) { await this.populateDetail();