From 519576ca4fb11c4f1cbb3e33ba3287b9e3f9a90e Mon Sep 17 00:00:00 2001 From: Brian Helba Date: Tue, 15 Jul 2025 21:49:53 -0400 Subject: [PATCH 1/2] Invert the Collection-Doi relationship and use DoiFactory directly in tests --- isic/conftest.py | 3 +- isic/core/admin.py | 2 +- isic/core/api/collection.py | 5 +- isic/core/api/doi.py | 4 +- isic/core/migrations/0026_invert_doi_fk.py | 76 +++++++++++++++++++ isic/core/models/collection.py | 12 --- isic/core/models/doi.py | 25 ++++-- isic/core/services/collection/__init__.py | 4 +- isic/core/services/collection/doi.py | 30 +++----- .../templates/core/collection_detail.html | 2 +- isic/core/tests/factories.py | 1 + isic/core/tests/test_doi.py | 19 +++-- isic/core/tests/test_sitemap.py | 7 +- isic/ingest/tests/test_merge.py | 51 ++++++------- 14 files changed, 155 insertions(+), 86 deletions(-) create mode 100644 isic/core/migrations/0026_invert_doi_fk.py diff --git a/isic/conftest.py b/isic/conftest.py index 30424e4c..065ce95e 100644 --- a/isic/conftest.py +++ b/isic/conftest.py @@ -15,7 +15,7 @@ get_elasticsearch_client, maybe_create_index, ) -from isic.core.tests.factories import CollectionFactory, DoiFactory, ImageFactory, IsicIdFactory +from isic.core.tests.factories import CollectionFactory, ImageFactory, IsicIdFactory from isic.ingest.tests.factories import ( AccessionFactory, AccessionReviewFactory, @@ -108,4 +108,3 @@ def s3ff_random_field_value(s3ff_field_value_factory): register(IsicIdFactory) register(ImageFactory) register(CollectionFactory) -register(DoiFactory) diff --git a/isic/core/admin.py b/isic/core/admin.py index ace10baf..25c27770 100644 --- a/isic/core/admin.py +++ b/isic/core/admin.py @@ -215,7 +215,7 @@ class SupplementalFileInline(admin.TabularInline): @admin.register(Doi) class DoiAdmin(StaffReadonlyAdmin): list_select_related = ["collection"] - list_display = ["id", "url", "collection", "bundle", "num_supplemental_files"] + list_display = ["id", "external_url", "collection", "bundle", "num_supplemental_files"] inlines = [SupplementalFileInline] autocomplete_fields = ["creator"] diff --git a/isic/core/api/collection.py b/isic/core/api/collection.py index 9f60ac25..d5716194 100644 --- a/isic/core/api/collection.py +++ b/isic/core/api/collection.py @@ -45,9 +45,10 @@ def query_min_length(cls, v: str): class CollectionOut(ModelSchema): class Meta: model = Collection - fields = ["id", "name", "description", "public", "pinned", "locked", "doi"] + fields = ["id", "name", "description", "public", "pinned", "locked"] - doi_url: str | None = Field(alias="doi_url") + doi: str | None = Field(None, alias="doi.id") + doi_url: str | None = Field(None, alias="doi.external_url") @router.get( diff --git a/isic/core/api/doi.py b/isic/core/api/doi.py index a59d8e4e..dedd2aee 100644 --- a/isic/core/api/doi.py +++ b/isic/core/api/doi.py @@ -41,7 +41,9 @@ def parse_s3_file_field_values(cls, v): # noqa: N805 include_in_schema=False, ) def create_doi(request, payload: CreateDOIIn): - collection = get_object_or_404(Collection, id=payload.collection_id) + collection = get_object_or_404( + Collection.objects.select_related("doi"), pk=payload.collection_id + ) if not request.user.is_staff: return 403, {"error": "You do not have permission to create a DOI."} diff --git a/isic/core/migrations/0026_invert_doi_fk.py b/isic/core/migrations/0026_invert_doi_fk.py new file mode 100644 index 00000000..c373653a --- /dev/null +++ b/isic/core/migrations/0026_invert_doi_fk.py @@ -0,0 +1,76 @@ +import django.core.validators +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps +from django.db.models import OuterRef, Subquery +import django.db.models.deletion + +import isic.core.models.doi + + +def invert_doi_fk(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor): + Doi = apps.get_model("core", "Doi") + Collection = apps.get_model("core", "Collection") + + Doi.objects.update( + collection=Subquery(Collection.objects.filter(doi=OuterRef("pk")).values("pk")[:1]) + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0025_alter_collection_shares_alter_image_shares"), + ] + + operations = [ + migrations.AlterField( + model_name="collection", + name="doi", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="collection_reverse", + to="core.doi", + ), + ), + migrations.AddField( + model_name="doi", + name="collection", + field=models.OneToOneField( + default=None, + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="core.collection", + ), + preserve_default=False, + ), + migrations.RunPython(invert_doi_fk, elidable=True), + migrations.RemoveField( + model_name="collection", + name="doi", + ), + migrations.AlterField( + model_name="doi", + name="collection", + field=models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + to="core.collection", + ), + ), + migrations.AlterField( + model_name="doi", + name="id", + field=models.CharField( + default=isic.core.models.doi._generate_random_doi_id, # noqa: SLF001 + max_length=30, + primary_key=True, + serialize=False, + validators=[django.core.validators.RegexValidator("^\\d+\\.\\d+/\\d+$")], + ), + ), + migrations.RemoveField( + model_name="doi", + name="url", + ), + ] diff --git a/isic/core/models/collection.py b/isic/core/models/collection.py index f46fbaac..6839969f 100644 --- a/isic/core/models/collection.py +++ b/isic/core/models/collection.py @@ -10,7 +10,6 @@ from django.urls import reverse from django_extensions.db.models import TimeStampedModel -from .doi import Doi from .image import Image @@ -83,8 +82,6 @@ class Meta(TimeStampedModel.Meta): pinned = models.BooleanField(default=False) - doi = models.OneToOneField(Doi, on_delete=models.PROTECT, null=True, blank=True) - locked = models.BooleanField(default=False) objects = CollectionQuerySet.as_manager() @@ -100,15 +97,6 @@ def is_magic(self) -> bool: """Magic collections are collections pointed to by a cohort.""" return hasattr(self, "cohort") - @property - def has_doi(self) -> bool: - return self.doi is not None - - @property - def doi_url(self): - if self.doi: - return f"https://doi.org/{self.doi}" - @property def num_lesions(self): return ( diff --git a/isic/core/models/doi.py b/isic/core/models/doi.py index 09b0f492..6cb431e3 100644 --- a/isic/core/models/doi.py +++ b/isic/core/models/doi.py @@ -1,5 +1,7 @@ import json +import random +from django.conf import settings from django.contrib.auth.models import User from django.core.files.storage import storages from django.core.validators import RegexValidator @@ -7,6 +9,8 @@ from django.urls import reverse from django_extensions.db.models import TimeStampedModel +from .collection import Collection + def doi_upload_to(instance: "Doi", filename: str) -> str: return f"dois/{instance.id.replace('/', '-')}/{filename}" @@ -16,19 +20,26 @@ def doi_storage(): return storages["sponsored"] +def _generate_random_doi_id(): + # pad DOI with leading zeros so all DOIs are prefix/6 digits + return f"{settings.ISIC_DATACITE_DOI_PREFIX}/{random.randint(10_000, 999_999):06}" # noqa: S311 + + class Doi(TimeStampedModel): class Meta: verbose_name = "DOI" verbose_name_plural = "DOIs" id = models.CharField( - max_length=30, primary_key=True, validators=[RegexValidator(r"^\d+\.\d+/\d+$")] + max_length=30, + primary_key=True, + default=_generate_random_doi_id, + validators=[RegexValidator(r"^\d+\.\d+/\d+$")], ) slug = models.SlugField(max_length=150, unique=True) + collection = models.OneToOneField(Collection, on_delete=models.PROTECT) creator = models.ForeignKey(User, on_delete=models.RESTRICT) - url = models.CharField(max_length=200) - bundle = models.FileField(upload_to=doi_upload_to, storage=doi_storage, null=True, blank=True) bundle_size = models.PositiveBigIntegerField(null=True, blank=True) @@ -38,10 +49,14 @@ class Meta: citations = models.JSONField(default=dict, blank=True) schema_org_dataset = models.JSONField(default=dict, blank=True) - def __str__(self): + def __str__(self) -> str: return self.id - def get_absolute_url(self): + @property + def external_url(self) -> str: + return f"https://doi.org/{self.id}" + + def get_absolute_url(self) -> str: return reverse("core/doi-detail", kwargs={"slug": self.slug}) def get_schema_org_dataset_json(self): diff --git a/isic/core/services/collection/__init__.py b/isic/core/services/collection/__init__.py index 58e29609..9500f529 100644 --- a/isic/core/services/collection/__init__.py +++ b/isic/core/services/collection/__init__.py @@ -58,7 +58,7 @@ def collection_delete(*, collection: Collection, ignore_lock: bool = False) -> N if collection.studies.exists(): raise ValidationError("Collections with derived studies cannot be deleted.") - if collection.has_doi: + if hasattr(collection, "doi"): raise ValidationError("Collections with DOIs cannot be deleted.") collection.delete() @@ -131,7 +131,7 @@ def collection_merge_magic_collections( if hasattr(src_collection, "cohort") and src_collection.cohort != dest_collection.cohort: logger.info("Abandoning cohort %s", src_collection.cohort.pk) - for field in ["public", "pinned", "doi", "locked"]: + for field in ["public", "pinned", "locked"]: dest_collection_value = getattr(dest_collection, field) collection_value = getattr(src_collection, field) if dest_collection_value != collection_value: diff --git a/isic/core/services/collection/doi.py b/isic/core/services/collection/doi.py index 2836a159..74e68b9e 100644 --- a/isic/core/services/collection/doi.py +++ b/isic/core/services/collection/doi.py @@ -1,6 +1,5 @@ import logging from pathlib import Path -import random from typing import TYPE_CHECKING, Any from urllib import parse @@ -19,7 +18,6 @@ from isic.core.services.collection import ( collection_get_creators_in_attribution_order, collection_lock, - collection_update, ) from isic.core.services.snapshot import snapshot_images from isic.core.tasks import ( @@ -101,17 +99,12 @@ def collection_build_draft_doi(*, doi_id: str) -> dict: } -def collection_generate_random_doi_id(): - # pad DOI with leading zeros so all DOIs are prefix/6 digits - return f"{settings.ISIC_DATACITE_DOI_PREFIX}/{random.randint(10_000, 999_999):06}" # noqa: S311 - - def collection_check_create_doi_allowed( *, user: User, collection: Collection, supplemental_files=None ) -> None: if not user.has_perm("core.create_doi", collection): raise ValidationError("You don't have permissions to do that.") - if collection.doi: + if hasattr(collection, "doi"): raise ValidationError("This collection already has a DOI.") if not collection.public: raise ValidationError("A collection must be public to issue a DOI.") @@ -175,21 +168,13 @@ def collection_create_doi(*, user: User, collection: Collection, supplemental_fi user=user, collection=collection, supplemental_files=supplemental_files ) - doi_id = collection_generate_random_doi_id() - draft_doi_dict = collection_build_draft_doi(doi_id=doi_id) - doi_dict = collection_build_doi(collection=collection, doi_id=doi_id) - with transaction.atomic(): # First, create the local DOI record to validate uniqueness within our known set - doi = Doi( - id=doi_id, slug=slugify(collection.name), creator=user, url=f"https://doi.org/{doi_id}" - ) + doi = Doi(slug=slugify(collection.name), collection=collection, creator=user) doi.full_clean() doi.save() - # Lock the collection, set the DOI on it collection_lock(collection=collection) - collection_update(collection=collection, doi=doi, ignore_lock=True) if supplemental_files: for supplemental_file in supplemental_files: @@ -200,17 +185,20 @@ def collection_create_doi(*, user: User, collection: Collection, supplemental_fi size=supplemental_file["blob"].size, ) + draft_doi_dict = collection_build_draft_doi(doi_id=doi.id) + doi_dict = collection_build_doi(collection=collection, doi_id=doi.id) + # Reserve the DOI using the draft mechanism. # If it fails, transaction will rollback, nothing in our database will change. _datacite_create_doi(draft_doi_dict) # Convert to a published DOI. If this fails, someone will have to come along later and # retry to publish it. (May want a django-admin action for this if it ever happens.) - _datacite_update_doi(doi_dict, doi_id) + _datacite_update_doi(doi_dict, doi.id) - create_doi_bundle_task.delay_on_commit(doi_id) - fetch_doi_citations_task.delay_on_commit(doi_id) - fetch_doi_schema_org_dataset_task.delay_on_commit(doi_id) + create_doi_bundle_task.delay_on_commit(doi.id) + fetch_doi_citations_task.delay_on_commit(doi.id) + fetch_doi_schema_org_dataset_task.delay_on_commit(doi.id) logger.info("User %d created DOI %s for collection %d", user.id, doi.id, collection.id) diff --git a/isic/core/templates/core/collection_detail.html b/isic/core/templates/core/collection_detail.html index 8a3dfb7d..c39557cd 100644 --- a/isic/core/templates/core/collection_detail.html +++ b/isic/core/templates/core/collection_detail.html @@ -38,7 +38,7 @@ {% if collection.doi %}
  • DOI: - {{ collection.doi.url }} + {{ collection.doi.external_url }}
  • {% endif %}
  • diff --git a/isic/core/tests/factories.py b/isic/core/tests/factories.py index 56da1605..7a385ac9 100644 --- a/isic/core/tests/factories.py +++ b/isic/core/tests/factories.py @@ -46,4 +46,5 @@ class Meta: model = Doi slug = factory.Faker("slug") + collection = factory.SubFactory(CollectionFactory) creator = factory.SubFactory(UserFactory) diff --git a/isic/core/tests/test_doi.py b/isic/core/tests/test_doi.py index e16a48b9..02ecb021 100644 --- a/isic/core/tests/test_doi.py +++ b/isic/core/tests/test_doi.py @@ -12,6 +12,7 @@ collection_build_doi, collection_create_doi, ) +from isic.core.tests.factories import CollectionFactory, DoiFactory @pytest.fixture @@ -69,18 +70,20 @@ def test_collection_create_doi( @pytest.mark.django_db -def test_doi_form_requires_public_collection(private_collection, staff_user_request): - with pytest.raises(ValidationError): - collection_create_doi(user=staff_user_request.user, collection=private_collection) +def test_doi_form_requires_public_collection(staff_user_request): + collection = CollectionFactory.create(public=False) + + with pytest.raises(ValidationError, match="must be public"): + collection_create_doi(user=staff_user_request.user, collection=collection) @pytest.mark.django_db -def test_doi_form_requires_no_existing_doi(public_collection, staff_user_request): - public_collection.doi = Doi.objects.create(id="foo", creator=staff_user_request.user, url="foo") - public_collection.save() +def test_doi_form_requires_no_existing_doi(staff_user_request): + collection = CollectionFactory.create(public=True) + DoiFactory.create(collection=collection, creator=staff_user_request.user) - with pytest.raises(ValidationError): - collection_create_doi(user=staff_user_request.user, collection=public_collection) + with pytest.raises(ValidationError, match="already has a DOI"): + collection_create_doi(user=staff_user_request.user, collection=collection) @pytest.mark.django_db(transaction=True) diff --git a/isic/core/tests/test_sitemap.py b/isic/core/tests/test_sitemap.py index defb69bf..0d4fe25d 100644 --- a/isic/core/tests/test_sitemap.py +++ b/isic/core/tests/test_sitemap.py @@ -1,10 +1,13 @@ from django.urls import reverse import pytest +from isic.core.tests.factories import DoiFactory + @pytest.mark.django_db -def test_sitemap_dois(client, doi_factory): - doi = doi_factory() +def test_sitemap_dois(client): + doi = DoiFactory.create() + response = client.get(reverse("django.contrib.sitemaps.views.sitemap")) assert response.status_code == 200 assert doi.slug in response.content.decode("utf-8") diff --git a/isic/ingest/tests/test_merge.py b/isic/ingest/tests/test_merge.py index 2ba15784..4f24aac7 100644 --- a/isic/ingest/tests/test_merge.py +++ b/isic/ingest/tests/test_merge.py @@ -1,14 +1,13 @@ from django.core.exceptions import ValidationError from django.urls import reverse import pytest -from pytest_lazy_fixtures import lf from isic.core.models.base import CopyrightLicense from isic.core.models.collection import Collection -from isic.core.models.doi import Doi from isic.core.services.collection import collection_merge_magic_collections from isic.core.services.collection.image import collection_add_images -from isic.core.tests.factories import CollectionFactory +from isic.core.tests.factories import CollectionFactory, DoiFactory +from isic.factories import UserFactory from isic.ingest.models.cohort import Cohort from isic.ingest.services.cohort import cohort_merge from isic.ingest.services.contributor import contributor_merge @@ -154,22 +153,6 @@ def test_merge_cohorts_view(full_cohort, staff_client): assert r.url == reverse("cohort-detail", args=[cohort_a.pk]) -@pytest.fixture -def collection_with_doi(collection, user): - collection.doi = Doi.objects.create( - id="10.1000/xyz123", creator=user, url="https://doi.org/10.1000/xyz123" - ) - collection.save() - return collection - - -@pytest.fixture -def collection_with_shares(collection, user_factory): - user = user_factory() - collection.shares.add(user, through_defaults={"grantor": collection.creator}) - return collection - - @pytest.fixture def full_collection(collection_factory, image_factory, cohort_factory): def _full_collection(*, public: bool): @@ -200,17 +183,27 @@ def test_merge_collections(full_collection): @pytest.mark.django_db -@pytest.mark.parametrize( - ("unmergeable_collection", "error"), - [ - (lf("collection_with_doi"), "DOI"), - (lf("collection_with_shares"), "shares"), - ], -) -def test_merge_collections_unmergeable(collection, unmergeable_collection, error): - with pytest.raises(ValidationError, match=error): +def test_merge_collections_unmergeable_doi(): + src_collection = CollectionFactory.create() + DoiFactory.create(collection=src_collection) + dest_collection = CollectionFactory.create() + + with pytest.raises(ValidationError, match="DOI"): + collection_merge_magic_collections( + dest_collection=dest_collection, src_collection=src_collection + ) + + +@pytest.mark.django_db +def test_merge_collections_unmergeable_shares(): + src_collection = CollectionFactory.create() + user = UserFactory.create() + src_collection.shares.add(user, through_defaults={"grantor": src_collection.creator}) + dest_collection = CollectionFactory.create() + + with pytest.raises(ValidationError, match="shares"): collection_merge_magic_collections( - dest_collection=collection, src_collection=unmergeable_collection + dest_collection=dest_collection, src_collection=src_collection ) From 5c5f116ea825b8990a621bfa20356c84b53a3bb4 Mon Sep 17 00:00:00 2001 From: Brian Helba Date: Wed, 16 Jul 2025 11:12:29 -0400 Subject: [PATCH 2/2] WIP: Move inline Javascript to no-build modules --- .../static/core/js/alpine/accessionsStore.js | 9 +++ .../data/collectionAttributionInformation.js | 15 ++++ .../core/js/alpine/data/collectionDetail.js | 69 +++++++++++++++++ .../core/js/alpine/data/collectionEditor.js | 48 ++++++++++++ .../static/core/js/alpine/data/quickfind.js | 25 ++++++ .../core/js/alpine/data/thumbnailGrid.js | 19 +++++ isic/core/static/core/js/axios.js | 18 +++++ isic/core/static/core/js/base.js | 19 +++++ isic/core/storages/static_files.py | 5 ++ isic/core/templates/core/base.html | 76 ++++++------------- .../templates/core/collection_detail.html | 70 ++--------------- isic/core/templates/core/image_browser.html | 8 +- .../core/image_detail/images_tab.html | 8 +- .../core/partials/collection_share_modal.html | 44 +---------- .../core/partials/edit_collection_js.html | 55 -------------- .../templates/ingest/cohort_detail.html | 9 +-- .../ingest/partials/thumbnail_grid_js.html | 23 ------ .../templates/ingest/review_gallery.html | 8 +- .../ingest/review_lesion_gallery.html | 2 - isic/settings/base.py | 2 +- .../templates/studies/study_detail.html | 7 +- 21 files changed, 274 insertions(+), 265 deletions(-) create mode 100644 isic/core/static/core/js/alpine/accessionsStore.js create mode 100644 isic/core/static/core/js/alpine/data/collectionAttributionInformation.js create mode 100644 isic/core/static/core/js/alpine/data/collectionDetail.js create mode 100644 isic/core/static/core/js/alpine/data/collectionEditor.js create mode 100644 isic/core/static/core/js/alpine/data/quickfind.js create mode 100644 isic/core/static/core/js/alpine/data/thumbnailGrid.js create mode 100644 isic/core/static/core/js/axios.js create mode 100644 isic/core/static/core/js/base.js create mode 100644 isic/core/storages/static_files.py delete mode 100644 isic/core/templates/core/partials/edit_collection_js.html delete mode 100644 isic/ingest/templates/ingest/partials/thumbnail_grid_js.html diff --git a/isic/core/static/core/js/alpine/accessionsStore.js b/isic/core/static/core/js/alpine/accessionsStore.js new file mode 100644 index 00000000..cf728918 --- /dev/null +++ b/isic/core/static/core/js/alpine/accessionsStore.js @@ -0,0 +1,9 @@ +export default () => ({ + items: {}, + setReview(id, value) { + this.items[id] = value; + }, + addItem(id) { + this.items[id] = null; + }, +}); diff --git a/isic/core/static/core/js/alpine/data/collectionAttributionInformation.js b/isic/core/static/core/js/alpine/data/collectionAttributionInformation.js new file mode 100644 index 00000000..288b0af7 --- /dev/null +++ b/isic/core/static/core/js/alpine/data/collectionAttributionInformation.js @@ -0,0 +1,15 @@ +import axiosSession from '../../axios.js'; + +export default (collectionId) => ({ + attributions: [], + fetched: false, + loading: false, + async fetchMetaInformation() { + this.loading = true; + const resp = await axiosSession.get(`/api/v2/collections/${collectionId}/attribution/`); + + this.attributions = resp.data; + this.loading = false; + this.fetched = true; + } +}); diff --git a/isic/core/static/core/js/alpine/data/collectionDetail.js b/isic/core/static/core/js/alpine/data/collectionDetail.js new file mode 100644 index 00000000..48a5cbcd --- /dev/null +++ b/isic/core/static/core/js/alpine/data/collectionDetail.js @@ -0,0 +1,69 @@ +import 'jquery'; // Defines "jQuery" and "$" + +import axiosSession from '../../axios.js'; + +function formatUser(user) { + if(user.loading) { + return user.text; + } + + const $container = $( + "
    " + + "" + + "
    " + user.first_name + " " + user.last_name + "
    " + + "
    " + ); + + return $container; +} + +export default (collectionId) => ({ + init() { + $("#user-selection").select2({ + ajax: { + url: '{% url "api:user_autocomplete" %}', + data: function (params) { + // remap the "term" parameter from select2 to "query" so it's + // consistent with other autocomplete endpoints. + return { + query: params.term + } + }, + processResults: function (data) { + return { + results: data + }; + }, + delay: 50, + }, + placeholder: 'Search for a user by email', + minimumInputLength: 3, + templateResult: formatUser, + templateSelection: function (user) { + return user.email || user.text; + } + }); + }, + modalOpen: false, + errorMessage: '', + async shareCollectionWithUsers() { + if ($('#user-selection').val().length === 0) { + this.errorMessage = 'Please select at least one user to share the collection with.'; + return; + } + + if (confirm('Are you sure you want to grant additional access to this collection?')) { + try { + const resp = await axiosSession.post(`/api/v2/collections/${collectionId}/share/`, { + user_ids: $('#user-selection').val().map(function (n) { + return parseInt(n) + }) + }); + } catch (error) { + this.errorMessage = error.response.data[0]; + return; + } + window.location.reload(); + } + }, +}); diff --git a/isic/core/static/core/js/alpine/data/collectionEditor.js b/isic/core/static/core/js/alpine/data/collectionEditor.js new file mode 100644 index 00000000..c42a1b1e --- /dev/null +++ b/isic/core/static/core/js/alpine/data/collectionEditor.js @@ -0,0 +1,48 @@ +import axiosSession from '../../axios.js'; + +export default (collectionId) => ({ + init() { + const existingStorage = localStorage.getItem(this.storageKey); + if (existingStorage !== null) { + this.images = new Set(JSON.parse(existingStorage)); + } + }, + storageKey: `images_to_remove_collection_${collectionId}`, + images: new Set(), + _imagesToArray() { + return Array.from(this.images.values()) + }, + _persist() { + localStorage.setItem(this.storageKey, JSON.stringify(this._imagesToArray())); + }, + toggleImage(imageId) { + if (this.images.has(imageId)) { + this.images.delete(imageId); + } else { + this.images.add(imageId); + } + + this._persist(); + }, + removeImage(imageId) { // TODO: unused?? + this._persist(); + }, + async deleteImages() { + if (confirm(`Are you sure you want to remove ${this.images.size} images from this collection?`)) { + try { + const resp = await axiosSession.post(`/api/v2/collections/${collectionId}/remove-from-list/`, { + 'isic_ids': this._imagesToArray() + }); + } catch (error) { + alert('Something went wrong.'); + return; + } + this.resetImages(`/collections/${collectionId}/`); + } + }, + resetImages(url) { + this.images.clear(); + this._persist(); + window.location.reload(url); + } +}); diff --git a/isic/core/static/core/js/alpine/data/quickfind.js b/isic/core/static/core/js/alpine/data/quickfind.js new file mode 100644 index 00000000..991f6f70 --- /dev/null +++ b/isic/core/static/core/js/alpine/data/quickfind.js @@ -0,0 +1,25 @@ +const controller = new AbortController(); + +export default () => ({ + quickfindOpen: false, + findText: '', + results: {}, + openQuickfindModal() { + this.$nextTick(() => this.$refs.quickfind.focus()); + this.quickfindOpen = true; + }, + closeQuickfindModal() { + this.quickfindOpen = false; + }, + async performFind() { + if (this.findText.length < 3) { + this.results = {}; + return; + } + + const { data } = await axiosSession.get(`/api/v2/quickfind/?query=${this.findText}`, { + signal: controller.signal, + }); + this.results = data; + }, +}); diff --git a/isic/core/static/core/js/alpine/data/thumbnailGrid.js b/isic/core/static/core/js/alpine/data/thumbnailGrid.js new file mode 100644 index 00000000..eeb1905a --- /dev/null +++ b/isic/core/static/core/js/alpine/data/thumbnailGrid.js @@ -0,0 +1,19 @@ +// allow between 4-8 columns +export default () => ({ + gridClassNames: { + 4: 'sm:grid-cols-4', + 5: 'sm:grid-cols-5', + 6: 'sm:grid-cols-6', + 7: 'sm:grid-cols-7', + 8: 'sm:grid-cols-8', + }, + numCols: parseInt(localStorage.getItem('numCols')) || 8, + increase() { + this.numCols = Math.min(8, this.numCols + 1); + localStorage.setItem('numCols', this.numCols); + }, + decrease() { + this.numCols = Math.max(4, this.numCols - 1); + localStorage.setItem('numCols', this.numCols); + } +}); diff --git a/isic/core/static/core/js/axios.js b/isic/core/static/core/js/axios.js new file mode 100644 index 00000000..bdaecb53 --- /dev/null +++ b/isic/core/static/core/js/axios.js @@ -0,0 +1,18 @@ +import axios from 'axios'; + +const axiosSession = axios.create({ + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': '{{ csrf_token }}' + } +}); + +axiosSession.interceptors.request.use((config) => { + const token = window.cookieStore.get('csrftoken'); + if (token) { + config.headers['X-CSRFToken'] = token.value; + } + return config; +}); + +export default axiosSession; diff --git a/isic/core/static/core/js/base.js b/isic/core/static/core/js/base.js new file mode 100644 index 00000000..6cb28685 --- /dev/null +++ b/isic/core/static/core/js/base.js @@ -0,0 +1,19 @@ +import Alpine from 'alpinejs'; + +import accessions from './alpine/accessionsStore.js'; + +import collectionDetail from './alpine/data/collectionDetail.js'; +import collectionEditor from './alpine/data/collectionEditor.js'; +import thumbnailGrid from './alpine/data/thumbnailGrid.js'; +import quickfind from './alpine/data/quickfind.js'; + + +document.addEventListener('alpine:init', () => { + Alpine.store('accessions', accessions); + + Alpine.data('collectionDetail', collectionDetail); + Alpine.data('collectionEditor', collectionEditor); + Alpine.data('thumbnailGrid', thumbnailGrid); + Alpine.data('quickfind', quickfind); +}); +Alpine.start() diff --git a/isic/core/storages/static_files.py b/isic/core/storages/static_files.py new file mode 100644 index 00000000..b6955fa1 --- /dev/null +++ b/isic/core/storages/static_files.py @@ -0,0 +1,5 @@ +from whitenoise.storage import CompressedManifestStaticFilesStorage + + +class JsModuleCompressedManifestStaticFilesStorage(CompressedManifestStaticFilesStorage): + support_js_module_import_aggregation = True diff --git a/isic/core/templates/core/base.html b/isic/core/templates/core/base.html index 8f0a927d..497e6597 100644 --- a/isic/core/templates/core/base.html +++ b/isic/core/templates/core/base.html @@ -17,9 +17,27 @@ {% if JS_SENTRY %} {% endif %} - - - + + + + + @@ -30,56 +48,6 @@ gtag('config', 'G-VBHRJSWF1T'); - - - - - {% endblock %} {% block content %} -
    +
    {{ collection.name }}
    @@ -30,7 +27,7 @@
    -
      +
      • Name: {{ collection.name }} @@ -55,7 +52,7 @@
      • Attribution: - + View
        @@ -145,17 +142,17 @@
    - - + +
    {% endif %} -
    +
    {% for image in images %} @@ -169,56 +166,5 @@
    {% include 'studies/partials/cursor_pagination.html' %} - - {% include 'ingest/partials/thumbnail_grid_js.html' %} - {% include 'core/partials/edit_collection_js.html' %} -
    - - {% endblock %} diff --git a/isic/core/templates/core/image_browser.html b/isic/core/templates/core/image_browser.html index 176ba7b1..b1ec16ad 100644 --- a/isic/core/templates/core/image_browser.html +++ b/isic/core/templates/core/image_browser.html @@ -77,10 +77,10 @@ {% include 'studies/partials/cursor_pagination.html' %}
    -
    +
    {% for image in images %} @@ -90,8 +90,6 @@
    {% include 'studies/partials/cursor_pagination.html' %} - - {% include 'ingest/partials/thumbnail_grid_js.html' %}
    {% include 'core/partials/image_browser_js.html' %} diff --git a/isic/core/templates/core/image_detail/images_tab.html b/isic/core/templates/core/image_detail/images_tab.html index 61e3f752..fea48bb8 100644 --- a/isic/core/templates/core/image_detail/images_tab.html +++ b/isic/core/templates/core/image_detail/images_tab.html @@ -1,9 +1,9 @@
    -
    +
    {% for image in images|slice:MAX_RELATED_SHOW_FIRST_N %} @@ -15,7 +15,5 @@
    Showing first {{ MAX_RELATED_SHOW_FIRST_N }} images.
    {% endif %}
    - - {% include 'ingest/partials/thumbnail_grid_js.html' %}
    diff --git a/isic/core/templates/core/partials/collection_share_modal.html b/isic/core/templates/core/partials/collection_share_modal.html index f8613932..c1f727dd 100644 --- a/isic/core/templates/core/partials/collection_share_modal.html +++ b/isic/core/templates/core/partials/collection_share_modal.html @@ -12,51 +12,9 @@
    -
    - - diff --git a/isic/core/templates/core/partials/edit_collection_js.html b/isic/core/templates/core/partials/edit_collection_js.html deleted file mode 100644 index beb6f908..00000000 --- a/isic/core/templates/core/partials/edit_collection_js.html +++ /dev/null @@ -1,55 +0,0 @@ - diff --git a/isic/ingest/templates/ingest/cohort_detail.html b/isic/ingest/templates/ingest/cohort_detail.html index 2171b69f..ae8192c8 100644 --- a/isic/ingest/templates/ingest/cohort_detail.html +++ b/isic/ingest/templates/ingest/cohort_detail.html @@ -19,10 +19,10 @@ {% include 'studies/partials/pagination.html' with page_obj=accessions %}
    -
    +
    {% for accession in accessions %} @@ -32,7 +32,4 @@
    {% include 'studies/partials/pagination.html' with page_obj=accessions %} - - {% include 'ingest/partials/thumbnail_grid_js.html' %} - {% endblock %} diff --git a/isic/ingest/templates/ingest/partials/thumbnail_grid_js.html b/isic/ingest/templates/ingest/partials/thumbnail_grid_js.html deleted file mode 100644 index cfc865f9..00000000 --- a/isic/ingest/templates/ingest/partials/thumbnail_grid_js.html +++ /dev/null @@ -1,23 +0,0 @@ - diff --git a/isic/ingest/templates/ingest/review_gallery.html b/isic/ingest/templates/ingest/review_gallery.html index e8db9f54..7736a946 100644 --- a/isic/ingest/templates/ingest/review_gallery.html +++ b/isic/ingest/templates/ingest/review_gallery.html @@ -19,14 +19,14 @@ {{ progress.num_reviewed|intcomma }} / {{ progress.num_reviewable|intcomma }} ({{ progress.percentage }}%) -
    +
    sorted by original filename
    @@ -39,7 +39,5 @@ {% if page_obj %} {% include 'ingest/partials/review_footer.html' %} {% endif %} - - {% include 'ingest/partials/thumbnail_grid_js.html' %} {% endif %} {% endblock %} diff --git a/isic/ingest/templates/ingest/review_lesion_gallery.html b/isic/ingest/templates/ingest/review_lesion_gallery.html index fef06757..234e03ec 100644 --- a/isic/ingest/templates/ingest/review_lesion_gallery.html +++ b/isic/ingest/templates/ingest/review_lesion_gallery.html @@ -38,7 +38,5 @@ {% if page_obj %} {% include 'ingest/partials/review_footer.html' %} {% endif %} - - {% include 'ingest/partials/thumbnail_grid_js.html' %} {% endif %} {% endblock %} diff --git a/isic/settings/base.py b/isic/settings/base.py index 21256871..e05c6dcf 100644 --- a/isic/settings/base.py +++ b/isic/settings/base.py @@ -105,7 +105,7 @@ STORAGES = { # Inject the "default" storage in particular run configurations "staticfiles": { - "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + "BACKEND": "isic.core.storages.static_files.JsModuleCompressedManifestStaticFilesStorage", }, } diff --git a/isic/studies/templates/studies/study_detail.html b/isic/studies/templates/studies/study_detail.html index f1379e0d..b442f767 100644 --- a/isic/studies/templates/studies/study_detail.html +++ b/isic/studies/templates/studies/study_detail.html @@ -83,11 +83,11 @@
    {% include 'studies/partials/pagination.html' with page_obj=images %} -
    +
    {% for image in images %} @@ -96,7 +96,6 @@
    {% include 'studies/partials/pagination.html' with page_obj=images %} - {% include 'ingest/partials/thumbnail_grid_js.html' %}
      {% for question in study.questions.all %}