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
12 changes: 12 additions & 0 deletions .greenmask/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
131 changes: 131 additions & 0 deletions isic/engagement/forms.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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 <select> narrowed to the chosen contributor's cohorts, so the widget
# here is only a safeguard: a stray {{ form.cohort }} emits a hidden input rather than an
# <option> for every cohort in the archive. the queryset is still what validates the post.
cohort = forms.ModelChoiceField(
widget=forms.HiddenInput(),
queryset=Cohort.objects.all(),
# a contributor without a cohort is a valid half provisioned state, so the cohort can be
# left for later. the reverse isn't allowed, see EngagementProfile's check constraint.
required=False,
label="Cohort",
help_text="The cohort carries the license applied to every image submitted under it.",
)

def clean(self):
cleaned_data = super().clean()
cleaned_data = cleaned_data if cleaned_data is not None else {}
contributor = cleaned_data.get("contributor")
cohort = cleaned_data.get("cohort")

if contributor and cohort and cohort.contributor_id != contributor.pk:
raise forms.ValidationError(
f"{cohort.name} belongs to {cohort.contributor.institution_name}, "
f"not {contributor.institution_name}."
)

return cleaned_data


class CondensedContributorCohortForm(forms.Form):
"""
Create a contributor and its first cohort in one step, to assign as a user's defaults.

A condensed version of the full contributor and cohort create forms: it asks only for the
fields that can't be defaulted, since staff are filling it out on someone else's behalf.
"""

# the fields that end up visible outside the Archive, badged as such in the template. the
# rest of the wording is deliberately terser than the model help text, which is written for
# contributors filling out the full upload forms rather than staff provisioning someone.
public_fields = ("default_attribution", "cohort_default_copyright_license")

institution_name = forms.CharField(
max_length=255,
label="Institution Name",
help_text="The full name of the affiliated institution. Private.",
widget=forms.TextInput(
attrs={
"class": "input input-bordered input-sm w-full",
"placeholder": "e.g. Memorial Sloan Kettering Cancer Center",
}
),
)
legal_contact_info = forms.CharField(
label="Legal Contact Information",
help_text="The person or institution responsible for legal inquiries about the data. "
"Private.",
widget=forms.Textarea(
attrs={
"class": "textarea textarea-bordered textarea-sm w-full",
"rows": 3,
"placeholder": "Name, title, email, and mailing address of the person or office "
"responsible for legal inquiries",
}
),
)
# required here even though the model allows it to be blank, because it's copied down to the
# cohort, where Cohort.default_attribution has no blank=True.
default_attribution = forms.CharField(
max_length=200,
label="Default Attribution",
help_text="Text that users of these images must reproduce to comply with Creative "
"Commons Attribution requirements.",
widget=forms.TextInput(
attrs={
"class": "input input-bordered input-sm w-full",
"placeholder": "e.g. Memorial Sloan Kettering Cancer Center",
}
),
)

cohort_name = forms.CharField(
max_length=255,
label="Cohort Name",
help_text="A short name for this group of images. Private.",
widget=forms.TextInput(
attrs={
"class": "input input-bordered input-sm w-full",
"placeholder": "e.g. MSK Dermoscopy 2024",
}
),
)
cohort_description = forms.CharField(
label="Cohort Description",
help_text="Private. Markdown supported.",
widget=forms.Textarea(
attrs={
"class": "textarea textarea-bordered textarea-sm w-full",
"rows": 3,
"placeholder": "Describe the dataset: imaging modality, patient population, "
"collection period, etc.",
}
),
)
cohort_default_copyright_license = forms.ChoiceField(
choices=[
("", "Select a license..."),
*(
(
value,
f"{label} (Not recommended)" if value == CopyrightLicense.CC_BY_NC else label,
)
for value, label in CopyrightLicense.choices
),
],
label="Default Copyright License",
widget=forms.Select(attrs={"class": "select select-bordered select-sm w-full"}),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 5.2.16 on 2026-08-05 17:52

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("engagement", "0002_emaildomaincontributor"),
("ingest", "0043_alter_rcmcase_id"),
]

operations = [
migrations.AlterField(
model_name="engagementprofile",
name="default_cohort",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="engagement_profiles",
to="ingest.cohort",
),
),
migrations.AlterField(
model_name="engagementprofile",
name="default_contributor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="engagement_profiles",
to="ingest.contributor",
),
),
]
8 changes: 6 additions & 2 deletions isic/engagement/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ class EngagementProfile(models.Model):
# deriving it would also mean a user needs a cohort before they have a contributor, which
# pushes towards a single catch-all "engagement platform" cohort.
default_contributor = models.ForeignKey(
Contributor, on_delete=models.PROTECT, null=True, related_name="engagement_profiles"
Contributor,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="engagement_profiles",
)
default_cohort = models.ForeignKey(
Cohort, on_delete=models.PROTECT, null=True, related_name="engagement_profiles"
Cohort, on_delete=models.PROTECT, null=True, blank=True, related_name="engagement_profiles"
)

class Meta:
Expand Down
82 changes: 56 additions & 26 deletions isic/engagement/services/email_domain/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
from collections.abc import Iterable

from allauth.account.models import EmailAddress
from django.contrib.auth.models import User
from django.db import transaction
from django.db.models import CharField, Func, OuterRef, Subquery
from django.db.models.functions import Lower, Trim

from isic.engagement.models import EmailDomainContributor, normalize_email_domain
from isic.engagement.models import EmailDomainContributor
from isic.ingest.models.cohort import Cohort
from isic.ingest.models.contributor import Contributor


class EmailDomain(Func):
"""The portion of an email address after the last @, or NULL when there is no @."""

function = "substring"
template = "%(function)s(%(expressions)s FROM '@([^@]*)$')"
output_field = CharField()


def create_email_domain_contributor(
*, domain: str, contributor: Contributor
) -> EmailDomainContributor:
Expand All @@ -29,36 +43,52 @@ def update_email_domain_contributor(
return email_domain_contributor


def suggest_contributor_for_email(email: str) -> Contributor | None:
"""Return the contributor mapped to an email address' domain, if any."""
_, at_sign, domain = email.rpartition("@")
if not at_sign:
return None

domain = normalize_email_domain(domain)
if not domain:
return None
def suggest_contributor_for_users(users: Iterable[User]) -> dict[int, Contributor]:
"""
Return the contributor suggested by each user's email addresses, keyed by user id.

email_domain_contributor = (
EmailDomainContributor.objects.select_related("contributor").filter(domain=domain).first()
A user can have several email addresses, so every one of them is considered rather than
only User.email. Unverified addresses are self-asserted and ignored, and the primary
address wins when more than one maps to a contributor. Users without a suggestion are
absent from the result.
"""
contributor_ids_by_user_id = dict(
EmailAddress.objects.filter(user__in=list(users), verified=True)
.annotate(email_domain=Lower(Trim(EmailDomain("email"))))
# narrowing to addresses that actually map is what makes DISTINCT ON correct. without
# it the highest priority verified address wins whether or not it maps to anything,
# so a user whose primary address is unmapped would get no suggestion at all even
# though one of their other addresses maps.
.filter(email_domain__in=EmailDomainContributor.objects.values("domain"))
.annotate(
suggested_contributor_id=Subquery(
EmailDomainContributor.objects.filter(domain=OuterRef("email_domain")).values(
"contributor"
)[:1]
)
)
# DISTINCT ON requires the ordering to lead with the distinct field, which happens to
# be the shape this wants anyway: the primary address wins, then the alphabetically
# first.
.order_by("user_id", "-primary", "email")
.distinct("user_id")
.values_list("user_id", "suggested_contributor_id")
)

return email_domain_contributor.contributor if email_domain_contributor else None
contributors = Contributor.objects.in_bulk(set(contributor_ids_by_user_id.values()))

return {
user_id: contributors[contributor_id]
for user_id, contributor_id in contributor_ids_by_user_id.items()
}

def suggest_contributor_for_user(user: User) -> Contributor | None:
"""
Return the contributor suggested by any of a user's email addresses.

A user can have several email addresses, so every one of them is considered rather than
only User.email. Unverified addresses are self-asserted and ignored, and the primary
address wins when more than one maps to a contributor.
def suggest_cohort_for_contributor(contributor: Contributor) -> Cohort | None:
"""
emails = user.emailaddress_set.filter(verified=True).order_by("-primary", "email") # type: ignore[attr-defined]

for email in emails.values_list("email", flat=True):
contributor = suggest_contributor_for_email(email)
if contributor:
return contributor
Return the cohort a contributor's uploads should land in, if it's unambiguous.

return None
Only a contributor with exactly one cohort produces a suggestion. Picking one of several
would be a guess, and the cohort determines the license every image is submitted under.
"""
cohorts = contributor.cohorts.all()[:2]
return cohorts[0] if len(cohorts) == 1 else None
33 changes: 33 additions & 0 deletions isic/engagement/services/profile/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from django.core.exceptions import ValidationError
from django.db import transaction

from isic.engagement.models import EngagementProfile
from isic.ingest.models.cohort import Cohort
from isic.ingest.models.contributor import Contributor


def assign_engagement_defaults(
*,
engagement_profile: EngagementProfile,
contributor: Contributor,
cohort: Cohort | None = None,
) -> None:
"""
Set an engagement user's defaults and give them access to them.

Recording the defaults isn't enough on its own. Uploading into a cohort requires ownership of
its contributor (see CohortPermissions.add_accession), so assignment grants that too, which
also reveals the contributor's existing cohorts and accessions to the user.
"""
if cohort is not None and cohort.contributor_id != contributor.pk:
raise ValidationError(
f"{cohort.name} belongs to a different contributor than {contributor.institution_name}."
)

with transaction.atomic():
engagement_profile.default_contributor = contributor
engagement_profile.default_cohort = cohort
engagement_profile.full_clean()
engagement_profile.save()

contributor.owners.add(engagement_profile.user)
Loading