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
2 changes: 2 additions & 0 deletions isic/core/api/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions isic/core/migrations/0043_image_image_pinned_implies_public.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
7 changes: 7 additions & 0 deletions isic/core/models/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
13 changes: 12 additions & 1 deletion isic/core/templates/core/image_detail/actions_menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,21 @@
<li>
<button @click="setPinned(false)">Unpin Image</button>
</li>
{% else %}
{% elif image.public %}
<li>
<button @click="setPinned(true)">Pin Image</button>
</li>
{% else %}
{% with pin_disabled_reason="Only public images can be pinned" %}
<li>
<span class="tooltip tooltip-left w-full" data-tip="{{ pin_disabled_reason }}">
<button class="w-full text-left" disabled aria-describedby="pin-disabled-reason">
Pin Image
</button>
</span>
<span id="pin-disabled-reason" class="sr-only">{{ pin_disabled_reason }}</span>
</li>
{% endwith %}
{% endif %}
{% endif %}
</ul>
Expand Down
27 changes: 26 additions & 1 deletion isic/core/tests/test_image_pin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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")