diff --git a/isic/core/constants.py b/isic/core/constants.py
index 18f4cc62..4eae8de0 100644
--- a/isic/core/constants.py
+++ b/isic/core/constants.py
@@ -1,2 +1,4 @@
MONGO_ID_REGEX = "[0-9a-f]{24}"
ISIC_ID_REGEX = "ISIC_[0-9]{7}"
+LESION_ID_REGEX = "IL_[0-9]{7}"
+PATIENT_ID_REGEX = "IP_[0-9]{7}"
diff --git a/isic/core/dsl.py b/isic/core/dsl.py
index 77624788..31eee327 100644
--- a/isic/core/dsl.py
+++ b/isic/core/dsl.py
@@ -113,6 +113,10 @@ def convert_term(s, loc, toks):
# isic_id can't be used with wildcards since it's a foreign key, so join the table and
# refer to the __id.
return "isic__id"
+ elif toks[0] == "lesion_id":
+ return "accession__lesion__id"
+ elif toks[0] == "patient_id":
+ return "accession__patient__id"
elif toks[0] == "age_approx":
return "accession__metadata__age__approx"
elif toks[0] == "copyright_license":
diff --git a/isic/core/models/collection.py b/isic/core/models/collection.py
index c3bd818d..8a885b65 100644
--- a/isic/core/models/collection.py
+++ b/isic/core/models/collection.py
@@ -99,6 +99,24 @@ def doi_url(self):
if self.doi:
return f"https://doi.org/{self.doi}"
+ @property
+ def num_lesions(self):
+ return (
+ self.images.exclude(accession__lesion_id=None)
+ .values("accession__lesion_id")
+ .distinct()
+ .count()
+ )
+
+ @property
+ def num_patients(self):
+ return (
+ self.images.exclude(accession__patient_id=None)
+ .values("accession__patient_id")
+ .distinct()
+ .count()
+ )
+
def full_clean(self, exclude=None, validate_unique=True):
if self.pk and self.public and self.images.private().exists():
raise ValidationError("Can't make collection public, it contains private images.")
diff --git a/isic/core/models/image.py b/isic/core/models/image.py
index 4bce0317..cdf88453 100644
--- a/isic/core/models/image.py
+++ b/isic/core/models/image.py
@@ -1,3 +1,5 @@
+from copy import deepcopy
+
from django.contrib.auth.models import User
from django.contrib.postgres.aggregates.general import ArrayAgg
from django.contrib.postgres.indexes import GinIndex, OpClass
@@ -77,25 +79,36 @@ def __str__(self):
def get_absolute_url(self):
return reverse("core/image-detail", args=[self.pk])
- @staticmethod
- def _image_metadata(accession_metadata: dict) -> dict:
- """
- Return the metadata for an image given its accession metadata.
+ @property
+ def has_patient(self) -> bool:
+ return self.accession.patient_id is not None
- Note that the metadata for the image includes a rounded age approximation, but not the
- original age.
+ @property
+ def has_lesion(self) -> bool:
+ return self.accession.lesion_id is not None
- The static version of this method is useful when dealing with dictionary representations.
+ @property
+ def metadata(self) -> dict:
"""
- if "age" in accession_metadata:
- accession_metadata["age_approx"] = Accession._age_approx(accession_metadata["age"])
- del accession_metadata["age"]
+ Return the metadata for an image.
- return accession_metadata
+ Note that the metadata for the image is sanitized unlike the metadata for the accession
+ which is behind the "firewall" of ingest. This includes rounded ages and obfuscated
+ longitudinal IDs.
+ """
+ image_metadata = deepcopy(self.accession.metadata)
- @property
- def metadata(self) -> dict:
- return Image._image_metadata(self.accession.metadata)
+ if "age" in image_metadata:
+ image_metadata["age_approx"] = Accession._age_approx(image_metadata["age"])
+ del image_metadata["age"]
+
+ if self.has_lesion:
+ image_metadata["lesion_id"] = self.accession.lesion_id
+
+ if self.has_patient:
+ image_metadata["patient_id"] = self.accession.patient_id
+
+ return image_metadata
def to_elasticsearch_document(self, body_only=False) -> dict:
# Can only be called on images that were fetched with with_elasticsearch_properties.
@@ -119,27 +132,25 @@ def to_elasticsearch_document(self, body_only=False) -> dict:
# index the document by image.pk so it can be updated later.
return {"_id": self.pk, "_source": document}
- def _with_same_metadata(self, metadata_key: str) -> QuerySet["Image"]:
- if self.accession.metadata.get(metadata_key):
- return (
- Image.objects.filter(accession__cohort_id=self.accession.cohort_id)
- .filter(
- **{
- f"accession__metadata__{metadata_key}": self.accession.metadata[
- metadata_key
- ]
- }
- )
- .exclude(pk=self.pk)
- )
- else:
+ def same_patient_images(self) -> QuerySet["Image"]:
+ if not self.has_patient:
return Image.objects.none()
- def same_patient_images(self) -> QuerySet["Image"]:
- return self._with_same_metadata("patient_id")
+ return (
+ Image.objects.filter(accession__cohort_id=self.accession.cohort_id)
+ .filter(**{"accession__patient_id": self.accession.patient_id})
+ .exclude(pk=self.pk)
+ )
def same_lesion_images(self) -> QuerySet["Image"]:
- return self._with_same_metadata("lesion_id")
+ if not self.has_lesion:
+ return Image.objects.none()
+
+ return (
+ Image.objects.filter(accession__cohort_id=self.accession.cohort_id)
+ .filter(**{"accession__lesion_id": self.accession.lesion_id})
+ .exclude(pk=self.pk)
+ )
class ImageShare(TimeStampedModel):
diff --git a/isic/core/serializers.py b/isic/core/serializers.py
index 1ab02263..a470ea3c 100644
--- a/isic/core/serializers.py
+++ b/isic/core/serializers.py
@@ -25,9 +25,11 @@ def valid_search_query(cls, value: str | None):
def collections_to_list(cls, value: str | list[int]):
if isinstance(value, str) and value:
return [int(x) for x in value.split(",")]
- elif isinstance(value, list):
+ elif isinstance(value, list) and len(value) == 1 and isinstance(value[0], str):
# TODO: this is a hack to get around the fact that ninja uses a swagger array input
# field for list types regardless.
+ return cls.collections_to_list(value[0])
+ elif isinstance(value, list) and value:
return value
return None
diff --git a/isic/core/services/__init__.py b/isic/core/services/__init__.py
index 0d4692e0..7d7ef181 100644
--- a/isic/core/services/__init__.py
+++ b/isic/core/services/__init__.py
@@ -11,19 +11,26 @@ class JsonKeys(Func):
function = "jsonb_object_keys"
-def _image_metadata_csv_headers(*, qs: QuerySet[Image]) -> list[str]:
+def image_metadata_csv_headers(*, qs: QuerySet[Image]) -> list[str]:
headers = ["isic_id", "attribution", "copyright_license"]
+ accession_qs = Accession.objects.filter(image__in=qs)
+
# depending on which queryset is passed in, the set of headers is different.
# get the superset of headers for this particular queryset.
used_metadata_keys = list(
- Accession.objects.filter(image__in=qs)
- .annotate(metadata_keys=JsonKeys("metadata"))
+ accession_qs.annotate(metadata_keys=JsonKeys("metadata"))
.order_by()
.values_list("metadata_keys", flat=True)
.distinct()
)
+ if accession_qs.exclude(lesion=None).exists():
+ used_metadata_keys.append("lesion_id")
+
+ if accession_qs.exclude(patient=None).exists():
+ used_metadata_keys.append("patient_id")
+
# TODO: this is a very leaky part of sensitive metadata handling that
# should be refactored.
if "age" in used_metadata_keys:
@@ -34,17 +41,33 @@ def _image_metadata_csv_headers(*, qs: QuerySet[Image]) -> list[str]:
def image_metadata_csv_rows(*, qs: QuerySet[Image]) -> Iterator[dict]:
+ # Note this uses .values because populating django ORM objects is very slow, and doing this on
+ # large querysets can add ~5s per 100k images to the request time.
for image in qs.order_by("isic_id").values(
"isic_id",
"accession__cohort__attribution",
"accession__copyright_license",
"accession__metadata",
+ "accession__lesion_id",
+ "accession__patient_id",
):
+ if "age" in image["accession__metadata"]:
+ image["accession__metadata"]["age_approx"] = Accession._age_approx(
+ image["accession__metadata"]["age"]
+ )
+ del image["accession__metadata"]["age"]
+
+ if image["accession__lesion_id"]:
+ image["accession__metadata"]["lesion_id"] = image["accession__lesion_id"]
+
+ if image["accession__patient_id"]:
+ image["accession__metadata"]["patient_id"] = image["accession__patient_id"]
+
yield {
**{
"isic_id": image["isic_id"],
"attribution": image["accession__cohort__attribution"],
"copyright_license": image["accession__copyright_license"],
- **Image._image_metadata(image["accession__metadata"]),
+ **image["accession__metadata"],
}
}
diff --git a/isic/core/templates/core/collection_detail.html b/isic/core/templates/core/collection_detail.html
index 972a2ce6..69ade630 100644
--- a/isic/core/templates/core/collection_detail.html
+++ b/isic/core/templates/core/collection_detail.html
@@ -56,6 +56,14 @@
Number of images:
{{ num_images|intcomma }}
+
+ Number of patients:
+ {{ collection.num_patients|intcomma }}
+
+
+ Number of lesions:
+ {{ collection.num_lesions|intcomma }}
+
Licenses:
diff --git a/isic/core/tests/test_dsl.py b/isic/core/tests/test_dsl.py
index bafeed15..af9c479e 100644
--- a/isic/core/tests/test_dsl.py
+++ b/isic/core/tests/test_dsl.py
@@ -11,6 +11,10 @@
# test isic_id especially due to the weirdness of the foreign key
["isic_id:ISIC_123*", Q(isic__id__startswith="ISIC_123")],
["isic_id:*123", Q(isic__id__endswith="123")],
+ ["lesion_id:IL_123*", Q(accession__lesion__id__startswith="IL_123")],
+ ["lesion_id:*123", Q(accession__lesion__id__endswith="123")],
+ ["patient_id:IP_123*", Q(accession__patient__id__startswith="IP_123")],
+ ["patient_id:*123", Q(accession__patient__id__endswith="123")],
['copyright_license:"CC-0"', Q(accession__copyright_license="CC-0")],
["diagnosis:foobar", Q(accession__metadata__diagnosis="foobar")],
['diagnosis:"foo bar"', Q(accession__metadata__diagnosis="foo bar")],
diff --git a/isic/core/tests/test_metadata_masking_api.py b/isic/core/tests/test_metadata_masking_api.py
new file mode 100644
index 00000000..be862f94
--- /dev/null
+++ b/isic/core/tests/test_metadata_masking_api.py
@@ -0,0 +1,55 @@
+import pytest
+
+from isic.core.models.image import Image
+from isic.core.services import image_metadata_csv_headers, image_metadata_csv_rows
+
+
+@pytest.fixture
+def image_with_maskable_metadata(image):
+ image.accession.update_metadata(
+ image.creator,
+ {
+ "age": 32,
+ "lesion_id": "supersecretlesionid",
+ "patient_id": "supersecretpatientid",
+ },
+ ignore_image_check=True,
+ )
+ return image
+
+
+@pytest.mark.django_db
+def test_accession_exposes_unsafe_metadata(image_with_maskable_metadata):
+ assert image_with_maskable_metadata.accession.metadata["age"] == 32
+ assert "age_approx" not in image_with_maskable_metadata.accession.metadata
+ assert "lesion_id" not in image_with_maskable_metadata.accession.metadata
+ assert "patient_id" not in image_with_maskable_metadata.accession.metadata
+
+
+@pytest.mark.django_db
+def test_image_exposes_safe_metadata(image_with_maskable_metadata):
+ assert image_with_maskable_metadata.metadata["age_approx"] == 30
+ assert "age" not in image_with_maskable_metadata.metadata
+ assert image_with_maskable_metadata.metadata["lesion_id"] != "supersecretlesionid"
+ assert image_with_maskable_metadata.metadata["patient_id"] != "supersecretpatientid"
+
+
+@pytest.mark.django_db
+def test_image_csv_headers_exposes_safe_metadata(image_with_maskable_metadata):
+ headers = image_metadata_csv_headers(qs=Image.objects.all())
+ assert "age" not in headers
+ assert "age_approx" in headers
+ assert "lesion_id" in headers
+ assert "patient_id" in headers
+
+
+@pytest.mark.django_db
+def test_image_csv_rows_exposes_safe_metadata(image_with_maskable_metadata):
+ rows = image_metadata_csv_rows(qs=Image.objects.all())
+ for row in rows:
+ assert "age" not in row
+ assert "age_approx" in row
+ assert "lesion_id" in row
+ assert "patient_id" in row
+ assert row["lesion_id"] != "supersecretlesionid"
+ assert row["patient_id"] != "supersecretpatientid"
diff --git a/isic/core/tests/test_view_image_detail.py b/isic/core/tests/test_view_image_detail.py
index d9179341..33334d8d 100644
--- a/isic/core/tests/test_view_image_detail.py
+++ b/isic/core/tests/test_view_image_detail.py
@@ -2,6 +2,8 @@
import pytest
from pytest_lazyfixture import lazy_fixture
+from isic.core.models.image import Image
+
@pytest.mark.django_db
@pytest.mark.parametrize(
@@ -25,24 +27,29 @@ def detailed_image(
image_factory, user_factory, study_factory, study_task_factory, collection_factory
):
user = user_factory()
+
metadata = {
"age": 32,
- "lesion_id": "IL_123456",
- "patient_id": "IP_123456",
+ "lesion_id": "IL_1234567",
+ "patient_id": "IP_1234567",
}
private_collection = collection_factory(public=False, pinned=True)
public_collection = collection_factory(public=True, pinned=True)
private_study = study_factory(public=False)
public_study = study_factory(public=True)
- main_image = image_factory(
- public=True, accession__metadata=metadata, accession__cohort__contributor__owners=[user]
+ main_image: Image = image_factory(
+ public=True,
+ accession__cohort__contributor__owners=[user],
)
- # create an image w/ the same lesion/patient ID
- image_factory(
- public=True, accession__metadata=metadata, accession__cohort__contributor__owners=[user]
+ # create an image w/ the same longitudinal information
+ secondary_image: Image = image_factory(
+ public=True,
+ accession__cohort__contributor__owners=[user],
)
+ main_image.accession.update_metadata(user, metadata, ignore_image_check=True)
+ secondary_image.accession.update_metadata(user, metadata, ignore_image_check=True)
private_collection.images.add(main_image)
public_collection.images.add(main_image)
diff --git a/isic/core/views/collections.py b/isic/core/views/collections.py
index 3f1e993b..ecf2112b 100644
--- a/isic/core/views/collections.py
+++ b/isic/core/views/collections.py
@@ -18,7 +18,7 @@
from isic.core.models import Collection
from isic.core.models.base import CopyrightLicense
from isic.core.permissions import get_visible_objects, needs_object_permission
-from isic.core.services import _image_metadata_csv_headers, image_metadata_csv_rows
+from isic.core.services import image_metadata_csv_headers, image_metadata_csv_rows
from isic.core.services.collection import collection_create, collection_update
from isic.core.services.collection.doi import (
collection_build_doi_preview,
@@ -117,7 +117,7 @@ def collection_download_metadata(request, pk):
"Content-Disposition"
] = f'attachment; filename="{slugify(collection.name)}_metadata_{current_time}.csv"'
- writer = csv.DictWriter(response, _image_metadata_csv_headers(qs=qs))
+ writer = csv.DictWriter(response, image_metadata_csv_headers(qs=qs))
writer.writeheader()
for metadata_row in image_metadata_csv_rows(qs=qs):
diff --git a/isic/ingest/migrations/0042_lesion_accession_lesion_lesion_unique_lesion_and_more.py b/isic/ingest/migrations/0042_lesion_accession_lesion_lesion_unique_lesion_and_more.py
new file mode 100644
index 00000000..bf373e3d
--- /dev/null
+++ b/isic/ingest/migrations/0042_lesion_accession_lesion_lesion_unique_lesion_and_more.py
@@ -0,0 +1,62 @@
+# Generated by Django 4.1.10 on 2023-08-25 21:41
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+import isic.ingest.models.lesion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0041_alter_metadatafile_blob"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Lesion",
+ fields=[
+ (
+ "id",
+ models.CharField(
+ default=isic.ingest.models.lesion._default_id,
+ max_length=12,
+ primary_key=True,
+ serialize=False,
+ validators=[django.core.validators.RegexValidator("^IL_[0-9]{7}$")],
+ ),
+ ),
+ ("private_lesion_id", models.CharField(max_length=255)),
+ (
+ "cohort",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="lesions",
+ to="ingest.cohort",
+ ),
+ ),
+ ],
+ ),
+ migrations.AddField(
+ model_name="accession",
+ name="lesion",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="accessions",
+ to="ingest.lesion",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="lesion",
+ constraint=models.UniqueConstraint(
+ fields=("private_lesion_id", "cohort"), name="unique_lesion"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="lesion",
+ constraint=models.CheckConstraint(
+ check=models.Q(("id__regex", "^IL_[0-9]{7}$")), name="lesion_id_valid_format"
+ ),
+ ),
+ ]
diff --git a/isic/ingest/migrations/0043_accession_ingest_acce_lesion__f2701b_idx.py b/isic/ingest/migrations/0043_accession_ingest_acce_lesion__f2701b_idx.py
new file mode 100644
index 00000000..1464059c
--- /dev/null
+++ b/isic/ingest/migrations/0043_accession_ingest_acce_lesion__f2701b_idx.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.10 on 2023-08-28 13:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0042_lesion_accession_lesion_lesion_unique_lesion_and_more"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="accession",
+ index=models.Index(
+ fields=["lesion_id", "id", "cohort_id"], name="ingest_acce_lesion__f2701b_idx"
+ ),
+ ),
+ ]
diff --git a/isic/ingest/migrations/0044_accession_ingest_acce_cohort__e73c08_idx.py b/isic/ingest/migrations/0044_accession_ingest_acce_cohort__e73c08_idx.py
new file mode 100644
index 00000000..b9a468e6
--- /dev/null
+++ b/isic/ingest/migrations/0044_accession_ingest_acce_cohort__e73c08_idx.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.10 on 2023-08-28 13:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0043_accession_ingest_acce_lesion__f2701b_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="accession",
+ index=models.Index(
+ fields=["cohort_id", "status", "created"], name="ingest_acce_cohort__e73c08_idx"
+ ),
+ ),
+ ]
diff --git a/isic/ingest/migrations/0045_metadataversion_lesion.py b/isic/ingest/migrations/0045_metadataversion_lesion.py
new file mode 100644
index 00000000..f9b365dd
--- /dev/null
+++ b/isic/ingest/migrations/0045_metadataversion_lesion.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.1.10 on 2023-08-30 13:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0044_accession_ingest_acce_cohort__e73c08_idx"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="metadataversion",
+ name="lesion",
+ field=models.JSONField(default=dict),
+ ),
+ ]
diff --git a/isic/ingest/migrations/0045_patient_accession_patient_patient_unique_patient_and_more.py b/isic/ingest/migrations/0045_patient_accession_patient_patient_unique_patient_and_more.py
new file mode 100644
index 00000000..a8cc8714
--- /dev/null
+++ b/isic/ingest/migrations/0045_patient_accession_patient_patient_unique_patient_and_more.py
@@ -0,0 +1,62 @@
+# Generated by Django 4.1.10 on 2023-08-29 14:56
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+import isic.ingest.models.patient
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0045_metadataversion_lesion"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Patient",
+ fields=[
+ (
+ "id",
+ models.CharField(
+ default=isic.ingest.models.patient._default_id,
+ max_length=10,
+ primary_key=True,
+ serialize=False,
+ validators=[django.core.validators.RegexValidator("^IP_[0-9]{7}$")],
+ ),
+ ),
+ ("private_patient_id", models.CharField(max_length=255)),
+ (
+ "cohort",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="patients",
+ to="ingest.cohort",
+ ),
+ ),
+ ],
+ ),
+ migrations.AddField(
+ model_name="accession",
+ name="patient",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="accessions",
+ to="ingest.patient",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="patient",
+ constraint=models.UniqueConstraint(
+ fields=("private_patient_id", "cohort"), name="unique_patient"
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="patient",
+ constraint=models.CheckConstraint(
+ check=models.Q(("id__regex", "^IP_[0-9]{7}$")), name="patient_id_valid_format"
+ ),
+ ),
+ ]
diff --git a/isic/ingest/migrations/0046_accession_accession_lesion_id_patient_id_exclusion.py b/isic/ingest/migrations/0046_accession_accession_lesion_id_patient_id_exclusion.py
new file mode 100644
index 00000000..85fe0a18
--- /dev/null
+++ b/isic/ingest/migrations/0046_accession_accession_lesion_id_patient_id_exclusion.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1.10 on 2023-08-29 15:18
+
+import django.contrib.postgres.constraints
+from django.contrib.postgres.operations import BtreeGistExtension
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0045_patient_accession_patient_patient_unique_patient_and_more"),
+ ]
+
+ operations = [
+ BtreeGistExtension(),
+ migrations.AddConstraint(
+ model_name="accession",
+ constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+ condition=models.Q(("lesion_id__isnull", False), ("patient_id__isnull", False)),
+ expressions=[("lesion_id", "="), ("patient_id", "<>")],
+ name="accession_lesion_id_patient_id_exclusion",
+ ),
+ ),
+ ]
diff --git a/isic/ingest/migrations/0047_metadataversion_patient.py b/isic/ingest/migrations/0047_metadataversion_patient.py
new file mode 100644
index 00000000..e9fae1e1
--- /dev/null
+++ b/isic/ingest/migrations/0047_metadataversion_patient.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.1.10 on 2023-08-30 13:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ingest", "0046_accession_accession_lesion_id_patient_id_exclusion"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="metadataversion",
+ name="patient",
+ field=models.JSONField(default=dict),
+ ),
+ ]
diff --git a/isic/ingest/models/__init__.py b/isic/ingest/models/__init__.py
index de4b5f33..acf2db91 100644
--- a/isic/ingest/models/__init__.py
+++ b/isic/ingest/models/__init__.py
@@ -3,18 +3,22 @@
from .cohort import Cohort
from .contributor import Contributor
from .distinctness_measure import DistinctnessMeasure
+from .lesion import Lesion
from .metadata_file import MetadataFile
from .metadata_version import MetadataVersion
+from .patient import Patient
from .zip_upload import ZipUpload
__all__ = [
"Accession",
- "AccessionStatus",
"AccessionReview",
+ "AccessionStatus",
"Cohort",
"Contributor",
"DistinctnessMeasure",
+ "Lesion",
"MetadataFile",
"MetadataVersion",
+ "Patient",
"ZipUpload",
]
diff --git a/isic/ingest/models/accession.py b/isic/ingest/models/accession.py
index 42855c15..cd446383 100644
--- a/isic/ingest/models/accession.py
+++ b/isic/ingest/models/accession.py
@@ -6,6 +6,7 @@
import PIL.Image
from django.contrib.auth.models import User
+from django.contrib.postgres.constraints import ExclusionConstraint
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db import models, transaction
@@ -18,6 +19,8 @@
from isic.core.models import CopyrightLicense, CreationSortedTimeStampedModel
from isic.ingest.models.cohort import Cohort
+from isic.ingest.models.lesion import Lesion
+from isic.ingest.models.patient import Patient
from isic.ingest.utils.mime import guess_mime_type
from isic.ingest.utils.zip import Blob
@@ -125,6 +128,24 @@ class Meta(CreationSortedTimeStampedModel.Meta):
& ~Q(blob_name="")
| ~Q(status=AccessionStatus.SUCCEEDED),
),
+ # identical lesion_id implies identical patient_id
+ ExclusionConstraint(
+ name="accession_lesion_id_patient_id_exclusion",
+ expressions=[
+ ("lesion_id", "="),
+ ("patient_id", "<>"),
+ ],
+ condition=Q(lesion_id__isnull=False) & Q(patient_id__isnull=False),
+ ),
+ ]
+
+ indexes = [
+ # useful for improving the performance of the cohort list page which needs per-cohort
+ # lesion counts.
+ models.Index(fields=["lesion_id", "id", "cohort_id"]),
+ # useful for improving the performance of the cohort detail page which needs to provide
+ # accession-wise status breakdowns.
+ models.Index(fields=["cohort_id", "status", "created"]),
]
# the creator is either inherited from the zip creator, or directly attached in the
@@ -167,6 +188,13 @@ class Meta(CreationSortedTimeStampedModel.Meta):
metadata = models.JSONField(default=dict)
unstructured_metadata = models.JSONField(default=dict)
+ lesion = models.ForeignKey(
+ Lesion, on_delete=models.SET_NULL, null=True, related_name="accessions"
+ )
+ patient = models.ForeignKey(
+ Patient, on_delete=models.SET_NULL, null=True, related_name="accessions"
+ )
+
objects = AccessionQuerySet.as_manager()
def __str__(self) -> str:
@@ -324,14 +352,38 @@ def update_metadata(
"""
Apply metadata to an accession from a row in a CSV.
- ALL metadata modifications must go through update_metadata/remove_metadata since they:
- 1) Check to see if the accession can be modified
- 2) Manage audit trails (MetadataVersion records)
- 3) Reset the review
+ ALL metadata modifications must go through update_metadata since it:
+ 1) Checks to see if the accession can be modified
+ 2) Manages audit trails (MetadataVersion records)
+ 3) Resets the review
+ 4) Manages remapping longitudinal fields
"""
if self.pk and not ignore_image_check:
self._require_unpublished()
+ def maybe_map_longitudinal_metadata(metadata: dict) -> bool:
+ mapped = False
+ parsed_lesion_id = metadata.get("lesion_id")
+ parsed_patient_id = metadata.get("patient_id")
+
+ if parsed_lesion_id and (
+ not self.lesion or self.lesion.private_lesion_id != parsed_lesion_id
+ ):
+ mapped = True
+ self.lesion, _ = self.cohort.lesions.get_or_create(
+ private_lesion_id=parsed_lesion_id
+ )
+
+ if parsed_patient_id and (
+ not self.patient or self.patient.private_patient_id != parsed_patient_id
+ ):
+ mapped = True
+ self.patient, _ = self.cohort.patients.get_or_create(
+ private_patient_id=parsed_patient_id
+ )
+
+ return mapped
+
with transaction.atomic():
modified = False
@@ -356,7 +408,17 @@ def update_metadata(
new_metadata = parsed_metadata.model_dump(
exclude_unset=True, exclude_none=True, exclude={"unstructured"}
)
- if new_metadata and original_metadata != new_metadata:
+ new_longitudinal_metadata = maybe_map_longitudinal_metadata(new_metadata)
+
+ # longitudinal metadata has already been captured, so strip it to prevent it from
+ # being added to the metadata and exposing the internal IDs.
+ if "lesion_id" in new_metadata:
+ del new_metadata["lesion_id"]
+
+ if "patient_id" in new_metadata:
+ del new_metadata["patient_id"]
+
+ if (new_metadata and original_metadata != new_metadata) or new_longitudinal_metadata:
modified = True
self.metadata.update(new_metadata)
@@ -372,6 +434,15 @@ def update_metadata(
creator=user,
metadata=self.metadata,
unstructured_metadata=self.unstructured_metadata,
+ lesion={"internal": self.lesion.private_lesion_id, "external": self.lesion_id}
+ if hasattr(self, "lesion") and self.lesion
+ else {},
+ patient={
+ "internal": self.patient.private_patient_id,
+ "external": self.patient_id,
+ }
+ if hasattr(self, "patient") and self.patient
+ else {},
)
self.save()
diff --git a/isic/ingest/models/cohort.py b/isic/ingest/models/cohort.py
index a68f71be..2062e69e 100644
--- a/isic/ingest/models/cohort.py
+++ b/isic/ingest/models/cohort.py
@@ -76,6 +76,14 @@ def __str__(self) -> str:
def get_absolute_url(self):
return reverse("cohort-detail", args=[self.id])
+ @property
+ def num_lesions(self):
+ return self.accessions.exclude(lesion=None).values("lesion__id").distinct().count()
+
+ @property
+ def num_patients(self):
+ return self.accessions.exclude(patient=None).values("patient__id").distinct().count()
+
class CohortPermissions:
model = Cohort
diff --git a/isic/ingest/models/lesion.py b/isic/ingest/models/lesion.py
new file mode 100644
index 00000000..8df3ed37
--- /dev/null
+++ b/isic/ingest/models/lesion.py
@@ -0,0 +1,40 @@
+import random
+
+from django.core.validators import RegexValidator
+from django.db import models
+from django.db.models.constraints import CheckConstraint, UniqueConstraint
+from django.db.models.query_utils import Q
+
+from isic.core.constants import LESION_ID_REGEX
+
+
+def _default_id():
+ while True:
+ lesion_id = f"IL_{random.randint(0, 9999999):07}"
+ # This has a race condition, so the actual creation should be retried or wrapped
+ # in a select for update on the Lesion table
+ if not Lesion.objects.filter(id=lesion_id).exists():
+ return lesion_id
+
+
+class Lesion(models.Model):
+ class Meta:
+ constraints = [
+ UniqueConstraint(fields=["private_lesion_id", "cohort"], name="unique_lesion"),
+ CheckConstraint(
+ name="lesion_id_valid_format",
+ check=Q(id__regex=f"^{LESION_ID_REGEX}$"),
+ ),
+ ]
+
+ id = models.CharField(
+ primary_key=True,
+ default=_default_id,
+ max_length=12,
+ validators=[RegexValidator(f"^{LESION_ID_REGEX}$")],
+ )
+ cohort = models.ForeignKey("Cohort", on_delete=models.CASCADE, related_name="lesions")
+ private_lesion_id = models.CharField(max_length=255)
+
+ def __str__(self):
+ return f"{self.private_lesion_id}->{self.id}"
diff --git a/isic/ingest/models/metadata_version.py b/isic/ingest/models/metadata_version.py
index cb22288b..11c468f3 100644
--- a/isic/ingest/models/metadata_version.py
+++ b/isic/ingest/models/metadata_version.py
@@ -30,6 +30,8 @@ class MetadataVersion(CreationSortedTimeStampedModel):
)
metadata = models.JSONField()
unstructured_metadata = models.JSONField()
+ lesion = models.JSONField(default=dict)
+ patient = models.JSONField(default=dict)
objects = MetadataVersionQuerySet.as_manager()
@@ -61,4 +63,6 @@ def _diff(a, b):
return {
"metadata": _diff(self.metadata, other.metadata),
"unstructured_metadata": _diff(self.unstructured_metadata, other.unstructured_metadata),
+ "lesion": _diff(self.lesion, other.lesion),
+ "patient": _diff(self.patient, other.patient),
}
diff --git a/isic/ingest/models/patient.py b/isic/ingest/models/patient.py
new file mode 100644
index 00000000..8bdeb46e
--- /dev/null
+++ b/isic/ingest/models/patient.py
@@ -0,0 +1,40 @@
+import random
+
+from django.core.validators import RegexValidator
+from django.db import models
+from django.db.models.constraints import CheckConstraint, UniqueConstraint
+from django.db.models.query_utils import Q
+
+from isic.core.constants import PATIENT_ID_REGEX
+
+
+def _default_id():
+ while True:
+ patient_id = f"IP_{random.randint(0, 9999999):07}"
+ # This has a race condition, so the actual creation should be retried or wrapped
+ # in a select for update on the patient table
+ if not Patient.objects.filter(id=patient_id).exists():
+ return patient_id
+
+
+class Patient(models.Model):
+ class Meta:
+ constraints = [
+ UniqueConstraint(fields=["private_patient_id", "cohort"], name="unique_patient"),
+ CheckConstraint(
+ name="patient_id_valid_format",
+ check=Q(id__regex=f"^{PATIENT_ID_REGEX}$"),
+ ),
+ ]
+
+ id = models.CharField(
+ primary_key=True,
+ default=_default_id,
+ max_length=10,
+ validators=[RegexValidator(f"^{PATIENT_ID_REGEX}$")],
+ )
+ cohort = models.ForeignKey("Cohort", on_delete=models.CASCADE, related_name="patients")
+ private_patient_id = models.CharField(max_length=255)
+
+ def __str__(self):
+ return f"{self.private_patient_id}->{self.id}"
diff --git a/isic/ingest/services/cohort/__init__.py b/isic/ingest/services/cohort/__init__.py
index 48422f63..a5b0220f 100644
--- a/isic/ingest/services/cohort/__init__.py
+++ b/isic/ingest/services/cohort/__init__.py
@@ -79,6 +79,14 @@ def cohort_merge(*, dest_cohort: Cohort, src_cohort: Cohort) -> None:
f"Found {overlapping_blob_names.count()} conflicting original blob names."
)
+ if (
+ src_cohort.lesions.exists()
+ or dest_cohort.lesions.exists()
+ or src_cohort.patients.exists()
+ or dest_cohort.patients.exists()
+ ):
+ raise ValidationError("Unable to merge cohorts with lesions or patients.")
+
with transaction.atomic():
# lock cohorts during merge
# TODO: This is kind of awkward because we need to lock all cohorts but only want to
diff --git a/isic/ingest/tasks.py b/isic/ingest/tasks.py
index 06939348..8e449410 100644
--- a/isic/ingest/tasks.py
+++ b/isic/ingest/tasks.py
@@ -12,7 +12,9 @@
AccessionStatus,
Cohort,
DistinctnessMeasure,
+ Lesion,
MetadataFile,
+ Patient,
ZipUpload,
)
from isic.ingest.services.cohort import cohort_publish
@@ -87,6 +89,10 @@ def update_metadata_task(user_pk: int, metadata_file_pk: int):
user = User.objects.get(pk=user_pk)
with transaction.atomic():
+ # Lock the longitudinal tables during metadata assignment
+ (_ for _ in Lesion.objects.select_for_update().all())
+ (_ for _ in Patient.objects.select_for_update().all())
+
# TODO: consider chunking in the future since large CSVs generate a lot of
# database traffic.
for _, row in metadata_file.to_df().iterrows():
diff --git a/isic/ingest/templates/ingest/cohort_list.html b/isic/ingest/templates/ingest/cohort_list.html
index ee7172a2..09c2a6a9 100644
--- a/isic/ingest/templates/ingest/cohort_list.html
+++ b/isic/ingest/templates/ingest/cohort_list.html
@@ -19,6 +19,8 @@
Cohort |
Default License |
# of Accessions |
+ # of Lesions |
+ # of Patients |
@@ -28,7 +30,9 @@
{% if row.display_attribution %}{{ row.cohort.attribution }}{% endif %} |
{{ row.cohort.name }} |
{{ row.cohort.default_copyright_license }} |
- {{ row.cohort.accessions.count|intcomma }} |
+ {{ row.accession_count|intcomma }} |
+ {{ row.lesion_count|intcomma }} |
+ {{ row.patient_count|intcomma }} |
{% endfor %}
diff --git a/isic/ingest/templates/ingest/partials/cohort_details.html b/isic/ingest/templates/ingest/partials/cohort_details.html
index 07a2af1f..1f89c24d 100644
--- a/isic/ingest/templates/ingest/partials/cohort_details.html
+++ b/isic/ingest/templates/ingest/partials/cohort_details.html
@@ -10,6 +10,8 @@
- Created by: {{ cohort.creator }}
- Created on: {% localtime cohort.created %}
- Number of accessions: {{ cohort.accessions.count|intcomma }}
+ - Number of patients: {{ cohort.num_patients | intcomma }}
+ - Number of lesions: {{ cohort.num_lesions | intcomma }}
- Published: {{ cohort.accessions.published.count|intcomma }}
- Unreviewed: {{ cohort.accessions.unreviewed.count|intcomma }}
- Accepted: {{ cohort.accessions.accepted.count|intcomma }}
diff --git a/isic/ingest/tests/test_longitudinal_metadata.py b/isic/ingest/tests/test_longitudinal_metadata.py
new file mode 100644
index 00000000..58630c24
--- /dev/null
+++ b/isic/ingest/tests/test_longitudinal_metadata.py
@@ -0,0 +1,90 @@
+import pytest
+
+from isic.ingest.models.accession import Accession
+
+
+@pytest.fixture
+def imageless_accession(accession_factory):
+ return accession_factory(image=None)
+
+
+@pytest.mark.django_db
+def test_accession_update_patient_metadata(user, accession_factory, cohort):
+ accession1 = accession_factory(image=None, cohort=cohort)
+ accession2 = accession_factory(image=None, cohort=cohort)
+
+ accession1.update_metadata(user, {"patient_id": "someinternalidentifier"})
+ assert accession1.patient.private_patient_id == "someinternalidentifier"
+
+ accession2.update_metadata(user, {"patient_id": "someinternalidentifier"})
+ assert accession1.patient.private_patient_id == "someinternalidentifier"
+
+ assert accession1.patient.id == accession2.patient.id
+
+
+@pytest.mark.django_db
+def test_accession_update_lesion_metadata(user, accession_factory, cohort):
+ accession1 = accession_factory(image=None, cohort=cohort)
+ accession2 = accession_factory(image=None, cohort=cohort)
+
+ accession1.update_metadata(user, {"lesion_id": "someinternalidentifier"})
+ assert accession1.lesion.private_lesion_id == "someinternalidentifier"
+
+ accession2.update_metadata(user, {"lesion_id": "someinternalidentifier"})
+ assert accession1.lesion.private_lesion_id == "someinternalidentifier"
+
+ assert accession1.lesion.id == accession2.lesion.id
+
+
+@pytest.mark.django_db
+def test_accession_update_patient_metadata_idempotent(user, imageless_accession: Accession):
+ imageless_accession.update_metadata(user, {"patient_id": "someinternalidentifier"})
+ longitudinal_id = imageless_accession.patient.id
+ assert imageless_accession.patient.private_patient_id == "someinternalidentifier"
+ assert "patient_id" not in imageless_accession.metadata
+ assert imageless_accession.metadata_versions.count() == 1
+
+ imageless_accession.update_metadata(user, {"patient_id": "someinternalidentifier"})
+ assert imageless_accession.patient.private_patient_id == "someinternalidentifier"
+ assert "patient_id" not in imageless_accession.metadata
+ assert imageless_accession.metadata_versions.count() == 1
+
+ assert imageless_accession.patient.id == longitudinal_id
+
+
+@pytest.mark.django_db
+def test_accession_update_lesion_metadata_idempotent(user, imageless_accession: Accession):
+ imageless_accession.update_metadata(user, {"lesion_id": "someinternalidentifier"})
+ longitudinal_id = imageless_accession.lesion.id
+ assert imageless_accession.lesion.private_lesion_id == "someinternalidentifier"
+ assert "lesion_id" not in imageless_accession.metadata
+ assert imageless_accession.metadata_versions.count() == 1
+
+ imageless_accession.update_metadata(user, {"lesion_id": "someinternalidentifier"})
+ assert imageless_accession.lesion.private_lesion_id == "someinternalidentifier"
+ assert "lesion_id" not in imageless_accession.metadata
+ assert imageless_accession.metadata_versions.count() == 1
+
+ assert imageless_accession.lesion.id == longitudinal_id
+
+
+@pytest.mark.django_db
+def test_accession_update_patient_metadata_change(user, imageless_accession):
+ imageless_accession.update_metadata(user, {"patient_id": "someinternalidentifier"})
+ assert imageless_accession.patient.private_patient_id == "someinternalidentifier"
+ assert imageless_accession.metadata_versions.count() == 1
+
+ imageless_accession.update_metadata(user, {"patient_id": "differentinternalidentifier"})
+ assert imageless_accession.patient.private_patient_id == "differentinternalidentifier"
+ assert imageless_accession.metadata_versions.count() == 2
+
+
+@pytest.mark.django_db
+def test_accession_update_lesion_metadata_change(user, imageless_accession):
+ imageless_accession.update_metadata(user, {"lesion_id": "someinternalidentifier"})
+ assert imageless_accession.lesion.private_lesion_id == "someinternalidentifier"
+ assert imageless_accession.metadata_versions.count() == 1
+
+ imageless_accession.update_metadata(user, {"lesion_id": "differentinternalidentifier"})
+ assert imageless_accession.lesion.private_lesion_id == "differentinternalidentifier"
+ assert imageless_accession.metadata_versions.count() == 2
diff --git a/isic/ingest/tests/test_merge.py b/isic/ingest/tests/test_merge.py
index dab9d762..55f7de03 100644
--- a/isic/ingest/tests/test_merge.py
+++ b/isic/ingest/tests/test_merge.py
@@ -78,6 +78,19 @@ def test_merge_cohorts_conflicting_original_blob_names(full_cohort):
cohort_merge(dest_cohort=cohort_a, src_cohort=cohort_b)
+@pytest.mark.django_db
+def test_merge_cohorts_with_longitudinal_metadata(full_cohort):
+ cohort_a, cohort_b = full_cohort(), full_cohort()
+
+ # set up longitudinal metadata
+ accession_a, accession_b = cohort_a.accessions.first(), cohort_b.accessions.first()
+ accession_a.update_metadata(accession_a.creator, {"patient_id": "foo"}, ignore_image_check=True)
+ accession_b.update_metadata(accession_b.creator, {"patient_id": "foo"}, ignore_image_check=True)
+
+ with pytest.raises(ValidationError, match="patients"):
+ cohort_merge(dest_cohort=cohort_a, src_cohort=cohort_b)
+
+
@pytest.mark.django_db
def test_merge_cohorts_heterogeneous_licenses(full_cohort):
cohort_a, cohort_b = full_cohort(), full_cohort()
diff --git a/isic/ingest/tests/test_metadata.py b/isic/ingest/tests/test_metadata.py
index 52767d8a..c576e209 100644
--- a/isic/ingest/tests/test_metadata.py
+++ b/isic/ingest/tests/test_metadata.py
@@ -188,6 +188,8 @@ def test_accession_metadata_versions(user, accession):
assert diffs[0][1] == {
"unstructured_metadata": {"added": {"foo": "bar"}, "removed": {}, "changed": {}},
"metadata": {"added": {}, "removed": {}, "changed": {}},
+ "lesion": {"added": {}, "removed": {}, "changed": {}},
+ "patient": {"added": {}, "removed": {}, "changed": {}},
}
accession.update_metadata(user, {"foo": "baz", "age": "45"})
@@ -197,6 +199,8 @@ def test_accession_metadata_versions(user, accession):
assert diffs[0][1] == {
"unstructured_metadata": {"added": {"foo": "bar"}, "removed": {}, "changed": {}},
"metadata": {"added": {}, "removed": {}, "changed": {}},
+ "lesion": {"added": {}, "removed": {}, "changed": {}},
+ "patient": {"added": {}, "removed": {}, "changed": {}},
}
assert diffs[1][1] == {
"unstructured_metadata": {
@@ -205,6 +209,8 @@ def test_accession_metadata_versions(user, accession):
"changed": {"foo": {"new_value": "baz", "old_value": "bar"}},
},
"metadata": {"added": {"age": 45}, "removed": {}, "changed": {}},
+ "lesion": {"added": {}, "removed": {}, "changed": {}},
+ "patient": {"added": {}, "removed": {}, "changed": {}},
}
diff --git a/isic/ingest/views/cohort.py b/isic/ingest/views/cohort.py
index f0ba7b42..7ceaf3d1 100644
--- a/isic/ingest/views/cohort.py
+++ b/isic/ingest/views/cohort.py
@@ -5,7 +5,7 @@
from django.contrib.humanize.templatetags.humanize import intcomma
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
-from django.db.models.query import Prefetch
+from django.db.models import Count
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls.base import reverse
@@ -20,9 +20,7 @@
@staff_member_required
def cohort_list(request):
- contributors = Contributor.objects.prefetch_related(
- Prefetch("cohorts", queryset=Cohort.objects.order_by("attribution", "name"))
- ).order_by("institution_name")
+ contributors = Contributor.objects.prefetch_related("cohorts").order_by("institution_name")
rows = []
for contributor in contributors.all():
@@ -32,6 +30,11 @@ def cohort_list(request):
):
display_attribution = True
for cohort in cohorts:
+ aggregate_data = cohort.accessions.aggregate(
+ lesion_count=Count("lesion", distinct=True),
+ patient_count=Count("patient", distinct=True),
+ accession_count=Count("pk"),
+ )
rows.append(
{
"contributor": contributor,
@@ -39,7 +42,9 @@ def cohort_list(request):
"attribution": attribution,
"display_attribution": display_attribution,
"cohort": cohort,
- "num_accessions": cohort.accessions.count(),
+ "accession_count": aggregate_data["accession_count"],
+ "lesion_count": aggregate_data["lesion_count"],
+ "patient_count": aggregate_data["patient_count"],
}
)
display_contributor = display_attribution = False
diff --git a/isic/ingest/views/review.py b/isic/ingest/views/review.py
index 3f503bb8..ff070375 100644
--- a/isic/ingest/views/review.py
+++ b/isic/ingest/views/review.py
@@ -63,12 +63,12 @@ def cohort_review(request, cohort_pk):
def _cohort_review_grouped_by_lesion(request, cohort: Cohort):
lesions_with_unreviewed_accessions = (
cohort.accessions.unreviewed()
- .values("metadata__lesion_id")
+ .values("lesion_id")
.alias(num_unreviewed_accessions=Count(1, filter=Q(review=None)))
.filter(num_unreviewed_accessions__gt=0)
- .values_list("metadata__lesion_id", flat=True)
+ .values_list("lesion_id", flat=True)
.distinct()
- .order_by("metadata__lesion_id")
+ .order_by("lesion_id")
)
paginator = Paginator(lesions_with_unreviewed_accessions, 50)
page = paginator.get_page(request.GET.get("page"))
@@ -83,7 +83,7 @@ def _cohort_review_grouped_by_lesion(request, cohort: Cohort):
.order_by("metadata__acquisition_day")
)
for accession in relevant_accessions:
- grouped_accessions[accession.metadata["lesion_id"]].append(accession)
+ grouped_accessions[accession.lesion_id].append(accession)
return render(
request,
diff --git a/isic/zip_download/api.py b/isic/zip_download/api.py
index f2fdb227..923b1d91 100644
--- a/isic/zip_download/api.py
+++ b/isic/zip_download/api.py
@@ -18,7 +18,7 @@
from isic.core.models import CopyrightLicense, Image
from isic.core.pagination import CursorPagination
from isic.core.serializers import SearchQueryIn
-from isic.core.services import _image_metadata_csv_headers, image_metadata_csv_rows
+from isic.core.services import image_metadata_csv_headers, image_metadata_csv_rows
logger = logging.getLogger(__name__)
zip_router = Router()
@@ -113,7 +113,7 @@ def zip_file_metadata_file(request: HttpRequest):
user, search = SearchQueryIn.from_token_representation(request.auth)
qs = search.to_queryset(user, Image.objects.select_related("accession__cohort").distinct())
response = HttpResponse(content_type="text/csv")
- writer = csv.DictWriter(response, _image_metadata_csv_headers(qs=qs))
+ writer = csv.DictWriter(response, image_metadata_csv_headers(qs=qs))
writer.writeheader()
for metadata_row in image_metadata_csv_rows(qs=qs):
diff --git a/setup.py b/setup.py
index caa55af0..6b55947e 100644
--- a/setup.py
+++ b/setup.py
@@ -62,7 +62,7 @@
"django-widget-tweaks",
"google-api-python-client",
"hashids",
- "isic-metadata>=0.1.0",
+ "isic-metadata>=0.2.0",
"jaro-winkler",
"more_itertools",
"oauth2client",