Skip to content

Commit 87f5b03

Browse files
Copilotdanlamanna
andcommitted
Add similar image feedback feature - model, API, admin, and UI
Co-authored-by: danlamanna <[email protected]>
1 parent f1172df commit 87f5b03

File tree

9 files changed

+520
-3
lines changed

9 files changed

+520
-3
lines changed

isic/core/admin.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44
from django.utils.html import format_html
55
from resonant_utils.admin import ReadonlyTabularInline
66

7-
from isic.core.models import Collection, Doi, GirderDataset, GirderImage, Image, ImageAlias
7+
from isic.core.models import (
8+
Collection,
9+
Doi,
10+
GirderDataset,
11+
GirderImage,
12+
Image,
13+
ImageAlias,
14+
SimilarImageFeedback,
15+
)
816
from isic.core.models.doi import DoiRelatedIdentifier, DraftDoi, DraftDoiRelatedIdentifier
917
from isic.core.models.segmentation import Segmentation, SegmentationReview
1018
from isic.core.models.supplemental_file import DraftSupplementalFile, SupplementalFile
@@ -261,3 +269,30 @@ class DraftDoiAdmin(BaseDoiAdmin):
261269
"is_publishing",
262270
]
263271
readonly_fields = ["is_publishing"]
272+
273+
274+
@admin.register(SimilarImageFeedback)
275+
class SimilarImageFeedbackAdmin(StaffReadonlyAdmin):
276+
list_display = ["id", "created", "user", "image_link", "similar_image_link", "feedback"]
277+
list_filter = ["feedback", "created"]
278+
search_fields = ["user__username", "image__isic_id", "similar_image__isic_id"]
279+
date_hierarchy = "created"
280+
ordering = ["-created"]
281+
282+
def image_link(self, obj):
283+
return format_html(
284+
'<a href="/images/{}">{}</a>',
285+
obj.image.isic_id,
286+
obj.image.isic_id,
287+
)
288+
289+
image_link.short_description = "Source Image"
290+
291+
def similar_image_link(self, obj):
292+
return format_html(
293+
'<a href="/images/{}">{}</a>',
294+
obj.similar_image.isic_id,
295+
obj.similar_image.isic_id,
296+
)
297+
298+
similar_image_link.short_description = "Similar Image"

isic/core/api/image.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,48 @@ def get_facets(request: HttpRequest, search: SearchQueryIn = Query(...)):
189189
def retrieve_image(request: HttpRequest, isic_id: str):
190190
qs = get_visible_objects(request.user, "core.view_image", default_qs)
191191
return get_object_or_404(qs, isic_id=isic_id)
192+
193+
194+
class SimilarImageFeedbackIn(Schema):
195+
similar_image_id: str
196+
feedback: str
197+
198+
199+
@router.post(
200+
"/{isic_id}/similar-feedback/",
201+
response={200: dict, 400: dict, 401: dict},
202+
summary="Submit feedback for a similar image recommendation.",
203+
include_in_schema=True,
204+
)
205+
def submit_similar_image_feedback(
206+
request: HttpRequest, isic_id: str, data: SimilarImageFeedbackIn
207+
):
208+
from isic.core.models import SimilarImageFeedback
209+
210+
if not request.user.is_authenticated:
211+
return 401, {"message": "Authentication required"}
212+
213+
# Validate feedback value
214+
if data.feedback not in [SimilarImageFeedback.THUMBS_UP, SimilarImageFeedback.THUMBS_DOWN]:
215+
return 400, {"message": "Invalid feedback value"}
216+
217+
# Get the source image
218+
qs = get_visible_objects(request.user, "core.view_image", default_qs)
219+
source_image = get_object_or_404(qs, isic_id=isic_id)
220+
221+
# Get the similar image
222+
similar_image = get_object_or_404(qs, isic_id=data.similar_image_id)
223+
224+
# Create or update feedback
225+
feedback, created = SimilarImageFeedback.objects.update_or_create(
226+
image=source_image,
227+
similar_image=similar_image,
228+
user=request.user,
229+
defaults={"feedback": data.feedback},
230+
)
231+
232+
return 200, {
233+
"message": "Feedback submitted successfully",
234+
"created": created,
235+
"feedback": feedback.feedback,
236+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Generated manually for similar image feedback feature
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import django_extensions.db.fields
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("core", "0035_image_image_embedding_ivfflat_idx"),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="SimilarImageFeedback",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
23+
),
24+
),
25+
(
26+
"created",
27+
django_extensions.db.fields.CreationDateTimeField(
28+
auto_now_add=True, verbose_name="created"
29+
),
30+
),
31+
(
32+
"modified",
33+
django_extensions.db.fields.ModificationDateTimeField(
34+
auto_now=True, verbose_name="modified"
35+
),
36+
),
37+
(
38+
"feedback",
39+
models.CharField(
40+
choices=[("up", "Thumbs Up"), ("down", "Thumbs Down")], max_length=10
41+
),
42+
),
43+
(
44+
"image",
45+
models.ForeignKey(
46+
help_text="The source image being viewed",
47+
on_delete=django.db.models.deletion.CASCADE,
48+
related_name="similarity_feedback_source",
49+
to="core.image",
50+
),
51+
),
52+
(
53+
"similar_image",
54+
models.ForeignKey(
55+
help_text="The similar image being rated",
56+
on_delete=django.db.models.deletion.CASCADE,
57+
related_name="similarity_feedback_target",
58+
to="core.image",
59+
),
60+
),
61+
(
62+
"user",
63+
models.ForeignKey(
64+
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
65+
),
66+
),
67+
],
68+
options={
69+
"verbose_name": "Similar Image Feedback",
70+
"verbose_name_plural": "Similar Image Feedback",
71+
"get_latest_by": "modified",
72+
"abstract": False,
73+
},
74+
),
75+
migrations.AddConstraint(
76+
model_name="similarimagefeedback",
77+
constraint=models.UniqueConstraint(
78+
fields=("image", "similar_image", "user"), name="similar_image_feedback_unique"
79+
),
80+
),
81+
]

isic/core/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from .collection_count import CollectionCount
88
from .doi import Doi
99
from .girder_image import GirderDataset, GirderImage
10-
from .image import Image
10+
from .image import Image, SimilarImageFeedback
1111
from .image_alias import ImageAlias
1212
from .isic_id import IsicId
1313
from .segmentation import Segmentation, SegmentationReview
@@ -27,6 +27,7 @@
2727
"IsicOAuthApplication",
2828
"Segmentation",
2929
"SegmentationReview",
30+
"SimilarImageFeedback",
3031
"SupplementalFile",
3132
]
3233

isic/core/models/image.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,50 @@ class Meta(TimeStampedModel.Meta):
260260
grantee = models.ForeignKey(User, on_delete=models.CASCADE)
261261

262262

263+
class SimilarImageFeedback(TimeStampedModel):
264+
"""
265+
Feedback for similar image search results.
266+
267+
Allows authenticated users to provide thumbs up/down feedback on similar image
268+
recommendations for auditing purposes.
269+
"""
270+
271+
class Meta(TimeStampedModel.Meta):
272+
constraints = [
273+
models.UniqueConstraint(
274+
name="similar_image_feedback_unique",
275+
fields=["image", "similar_image", "user"],
276+
),
277+
]
278+
verbose_name = "Similar Image Feedback"
279+
verbose_name_plural = "Similar Image Feedback"
280+
281+
THUMBS_UP = "up"
282+
THUMBS_DOWN = "down"
283+
FEEDBACK_CHOICES = [
284+
(THUMBS_UP, "Thumbs Up"),
285+
(THUMBS_DOWN, "Thumbs Down"),
286+
]
287+
288+
image = models.ForeignKey(
289+
Image,
290+
on_delete=models.CASCADE,
291+
related_name="similarity_feedback_source",
292+
help_text="The source image being viewed",
293+
)
294+
similar_image = models.ForeignKey(
295+
Image,
296+
on_delete=models.CASCADE,
297+
related_name="similarity_feedback_target",
298+
help_text="The similar image being rated",
299+
)
300+
user = models.ForeignKey(User, on_delete=models.CASCADE)
301+
feedback = models.CharField(max_length=10, choices=FEEDBACK_CHOICES)
302+
303+
def __str__(self):
304+
return f"{self.user.username}: {self.image.isic_id} -> {self.similar_image.isic_id} ({self.feedback})"
305+
306+
263307
class ImagePermissions:
264308
model = Image
265309
perms = ["view_image", "view_full_metadata"]

isic/core/templates/core/image_detail/base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
{% include 'core/image_detail/studies_tab.html' %}
7676

7777
{% if 'similar_images' in sections %}
78-
{% include 'core/image_detail/images_tab.html' with images=similar_images section_name='similar_images' lazy=1 %}
78+
{% include 'core/image_detail/similar_images_tab.html' with source_image=image similar_images_count=similar_images.count %}
7979
{% endif %}
8080

8181
{% if 'patient_images' in sections %}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<div x-show="selectedTab == 'similar_images'">
2+
<div class="bg-white shadow overflow-hidden sm:rounded-md">
3+
<div class="mb-4" x-data="thumbnailGrid();">
4+
<div class="hidden pb-3 sm:flex sm:flex-row sm:justify-end">
5+
<a class="px-1" href="#" @click="decrease();">fewer columns</a> |
6+
<a class="px-1" href="#" @click="increase();">more columns</a>
7+
</div>
8+
<div class="grid gap-4 grid-cols-2" :class="gridClassNames[numCols]">
9+
{% for image in similar_images|slice:MAX_RELATED_SHOW_FIRST_N %}
10+
{% include 'core/partials/similar_image_with_feedback.html' with source_image_id=source_image.isic_id %}
11+
{% endfor %}
12+
</div>
13+
14+
{% if similar_images_count and similar_images_count > MAX_RELATED_SHOW_FIRST_N %}
15+
<div>Showing first {{ MAX_RELATED_SHOW_FIRST_N }} images.</div>
16+
{% endif %}
17+
</div>
18+
19+
{% include 'ingest/partials/thumbnail_grid_js.html' %}
20+
</div>
21+
</div>
22+
23+
<script>
24+
function similarImageFeedback(sourceImageId, similarImageId) {
25+
return {
26+
feedback: null,
27+
async submitFeedback(feedbackType) {
28+
const previousFeedback = this.feedback;
29+
this.feedback = feedbackType;
30+
31+
try {
32+
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
33+
const response = await fetch(`/api/v2/images/${sourceImageId}/similar-feedback/`, {
34+
method: 'POST',
35+
headers: {
36+
'Content-Type': 'application/json',
37+
'X-CSRFToken': csrfToken || ''
38+
},
39+
body: JSON.stringify({
40+
similar_image_id: similarImageId,
41+
feedback: feedbackType
42+
})
43+
});
44+
45+
if (!response.ok) {
46+
throw new Error('Failed to submit feedback');
47+
}
48+
} catch (error) {
49+
console.error('Error submitting feedback:', error);
50+
this.feedback = previousFeedback;
51+
alert('Failed to submit feedback. Please try again.');
52+
}
53+
}
54+
};
55+
}
56+
</script>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{% load accession %}
2+
3+
<div>
4+
<div x-data="{ open: false, hovered: false }" class="pb-4">
5+
{% block thumb %}
6+
{% include 'core/partials/image_thumb.html' %}
7+
{% endblock %}
8+
</div>
9+
10+
{% block below_thumb %}
11+
<div class="flex justify-between items-center">
12+
<div class="flex">
13+
<a href="{{image.get_absolute_url}}">{{image.isic_id}}</a>
14+
15+
<a href="{{ image.blob.url }}">
16+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-2" viewBox="0 0 20 20" fill="currentColor">
17+
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"></path>
18+
</svg>
19+
</a>
20+
</div>
21+
22+
{% if user.is_authenticated %}
23+
<div class="flex gap-2" x-data="similarImageFeedback('{{ source_image_id }}', '{{ image.isic_id }}')">
24+
<button @click="submitFeedback('up')" :class="{'text-green-600': feedback === 'up', 'text-gray-400 hover:text-green-600': feedback !== 'up'}" class="transition-colors" title="Thumbs up">
25+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
26+
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
27+
</svg>
28+
</button>
29+
<button @click="submitFeedback('down')" :class="{'text-red-600': feedback === 'down', 'text-gray-400 hover:text-red-600': feedback !== 'down'}" class="transition-colors" title="Thumbs down">
30+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
31+
<path d="M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.105-1.79l-.05-.025A4 4 0 0011.055 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" />
32+
</svg>
33+
</button>
34+
</div>
35+
{% endif %}
36+
</div>
37+
{% endblock %}
38+
</div>

0 commit comments

Comments
 (0)