From 73bfc60d6f7cbb5a5eb05be9576c75a1ad8d1c25 Mon Sep 17 00:00:00 2001 From: Dan LaManna Date: Thu, 2 Jul 2026 13:20:06 -0400 Subject: [PATCH] Require pinned images to be public --- isic/core/api/image.py | 2 ++ .../0043_image_image_pinned_implies_public.py | 22 +++++++++++++++ isic/core/models/image.py | 7 +++++ .../core/image_detail/actions_menu.html | 13 ++++++++- isic/core/tests/test_image_pin.py | 27 ++++++++++++++++++- 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 isic/core/migrations/0043_image_image_pinned_implies_public.py diff --git a/isic/core/api/image.py b/isic/core/api/image.py index 3276c55f..a0a69cfa 100644 --- a/isic/core/api/image.py +++ b/isic/core/api/image.py @@ -309,6 +309,8 @@ def image_set_pinned(request, id: int, payload: SetPinned): qs = get_visible_objects(request.user, "core.view_image", Image.objects.all()) image = get_object_or_404(qs.distinct(), id=id) if payload.pinned: + if not image.public: + return 400, {"error": "Cannot pin a private image."} last_pin = Image.objects.aggregate(Max("pinned")).get("pinned__max") or 0 image.pinned = last_pin + 1 else: diff --git a/isic/core/migrations/0043_image_image_pinned_implies_public.py b/isic/core/migrations/0043_image_image_pinned_implies_public.py new file mode 100644 index 00000000..09d89d14 --- /dev/null +++ b/isic/core/migrations/0043_image_image_pinned_implies_public.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.15 on 2026-07-02 17:17 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0042_pinned_images"), + ("ingest", "0043_alter_rcmcase_id"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddConstraint( + model_name="image", + constraint=models.CheckConstraint( + condition=models.Q(("pinned__isnull", True), ("public", True), _connector="OR"), + name="image_pinned_implies_public", + ), + ), + ] diff --git a/isic/core/models/image.py b/isic/core/models/image.py index 42e53c7f..5e3f445d 100644 --- a/isic/core/models/image.py +++ b/isic/core/models/image.py @@ -68,6 +68,13 @@ class Image(CreationSortedTimeStampedModel): class Meta(CreationSortedTimeStampedModel.Meta): ordering = ["created"] + constraints = [ + CheckConstraint( + name="image_pinned_implies_public", + condition=Q(pinned__isnull=True) | Q(public=True), + ), + ] + indexes = [ # icontains uses Upper(name) for searching GinIndex(OpClass(Upper("isic"), name="gin_trgm_ops"), name="isic_name_gin"), diff --git a/isic/core/templates/core/image_detail/actions_menu.html b/isic/core/templates/core/image_detail/actions_menu.html index ec01a76a..59c8d48f 100644 --- a/isic/core/templates/core/image_detail/actions_menu.html +++ b/isic/core/templates/core/image_detail/actions_menu.html @@ -10,10 +10,21 @@
  • - {% else %} + {% elif image.public %}
  • + {% else %} + {% with pin_disabled_reason="Only public images can be pinned" %} +
  • + + + + {{ pin_disabled_reason }} +
  • + {% endwith %} {% endif %} {% endif %} diff --git a/isic/core/tests/test_image_pin.py b/isic/core/tests/test_image_pin.py index 6f81b6b3..df9fab02 100644 --- a/isic/core/tests/test_image_pin.py +++ b/isic/core/tests/test_image_pin.py @@ -30,6 +30,19 @@ def test_core_api_image_set_pinned_permissions(client_, expected_status, image_f assert image.pinned is None +@pytest.mark.django_db +def test_core_api_image_set_pinned_private_image_rejected(staff_client, image_factory): + image = image_factory(public=False) + r = staff_client.post( + reverse("api:image_set_pinned", kwargs={"id": image.pk}), + {"pinned": True}, + content_type="application/json", + ) + assert r.status_code == 400 + image.refresh_from_db() + assert image.pinned is None + + @pytest.mark.django_db def test_core_api_image_sort_by_pinned(image_factory, authenticated_client): image_1 = image_factory(public=True) @@ -49,7 +62,7 @@ def test_core_api_image_sort_by_pinned(image_factory, authenticated_client): @pytest.mark.playwright def test_image_pin_unpin(image_factory, staff_authenticated_page): page = staff_authenticated_page - image_id = image_factory().isic_id + image_id = image_factory(public=True).isic_id page.goto(reverse("core/image-detail", args=[image_id])) # Pin the image @@ -70,3 +83,15 @@ def test_image_pin_unpin(image_factory, staff_authenticated_page): # Pin button should be back page.get_by_role("button", name="Actions").click() expect(page.get_by_role("button", name="Pin image")).to_be_visible() + + +@pytest.mark.playwright +def test_image_pin_disabled_when_private(image_factory, staff_authenticated_page): + page = staff_authenticated_page + image_id = image_factory(public=False).isic_id + page.goto(reverse("core/image-detail", args=[image_id])) + + page.get_by_role("button", name="Actions").click() + pin_button = page.get_by_role("button", name="Pin image") + expect(pin_button).to_be_disabled() + expect(pin_button).to_have_accessible_description("Only public images can be pinned")