Too many changes :(

This commit is contained in:
Ross
2025-07-29 09:20:58 +01:00
parent ad4be1e9a6
commit 4460545442
20 changed files with 361 additions and 81 deletions
+34
View File
@@ -176,6 +176,37 @@ $(document).ready(function () {
}); });
// Table row selection
// Delegate click event to all table rows
document.querySelectorAll('.row-selector tbody tr').forEach(function(row) {
row.addEventListener('click', function(e) {
// Ignore clicks on links or checkboxes
if (e.target.tagName === 'A' || e.target.tagName === 'INPUT') return;
// Find the checkbox in this row
const checkbox = row.querySelector('input[name="selection"]');
if (checkbox) {
checkbox.checked = !checkbox.checked;
row.classList.toggle('selected', checkbox.checked);
}
});
// Keep row visually in sync with checkbox state (e.g. after page reload)
const checkbox = row.querySelector('input[name="selection"]');
if (checkbox && checkbox.checked) {
row.classList.add('selected');
}
// Also toggle row selection when checkbox is clicked directly
if (checkbox) {
checkbox.addEventListener('click', function(e) {
row.classList.toggle('selected', checkbox.checked);
// Prevent row click event from firing
e.stopPropagation();
});
}
});
}); });
window.loadDicomViewerEvent = new Event('loadDicomViewer'); window.loadDicomViewerEvent = new Event('loadDicomViewer');
@@ -482,6 +513,8 @@ async function setUpDicom(element) {
} }
window.setUpDicom = setUpDicom;
//function keyUpHandler(e) { //function keyUpHandler(e) {
// if (e.key == "Control") { // if (e.key == "Control") {
// dicomViewer.registerPrimaryDicomInterface(e); // dicomViewer.registerPrimaryDicomInterface(e);
@@ -682,3 +715,4 @@ function initializeClock(id, endtime) {
} }
window.initializeClock = initializeClock window.initializeClock = initializeClock
+20
View File
@@ -183,6 +183,8 @@ class CaseTable(tables.Table):
) )
sequence = ("view", ) sequence = ("view", )
order_by = "-created_date" order_by = "-created_date"
attrs = {"class": "table row-selector"}
class PopupLinkColumn(tables.Column): class PopupLinkColumn(tables.Column):
@@ -231,6 +233,8 @@ class SeriesTable(tables.Table):
) )
sequence = ("view", "popup", "images", "case") sequence = ("view", "popup", "images", "case")
order_by = "-created_date" order_by = "-created_date"
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__( super().__init__(
@@ -271,6 +275,8 @@ class ConditionTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum") fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__( super().__init__(
@@ -307,6 +313,8 @@ class FindingTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary") fields = ("name", "primary")
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return format_html(record.get_synonym_link())
@@ -334,6 +342,8 @@ class StructureTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name", "primary") fields = ("name", "primary")
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return format_html(record.get_synonym_link())
@@ -360,6 +370,8 @@ class PresentationTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name",) fields = ("name",)
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
class PathologicalProcessTable(tables.Table): class PathologicalProcessTable(tables.Table):
@@ -381,6 +393,8 @@ class PathologicalProcessTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name",) fields = ("name",)
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
class SubspecialtyTable(tables.Table): class SubspecialtyTable(tables.Table):
@@ -402,6 +416,8 @@ class SubspecialtyTable(tables.Table):
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = () fields = ()
sequence = ("name",) sequence = ("name",)
attrs = {"class": "table row-selector"}
def render_synonym(self, value, record): def render_synonym(self, value, record):
return format_html(record.get_synonym_link()) return format_html(record.get_synonym_link())
@@ -436,6 +452,8 @@ class CaseCollectionTable(tables.Table):
"author", "author",
) )
sequence = ("view", )#, "series") sequence = ("view", )#, "series")
attrs = {"class": "table row-selector"}
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
super().__init__( super().__init__(
@@ -463,6 +481,8 @@ class QuestionSchemaTable(tables.Table):
model = QuestionSchema model = QuestionSchema
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ("name", "description", "schema") fields = ("name", "description", "schema")
attrs = {"class": "table row-selector"}
def render_schema(self, value, record): def render_schema(self, value, record):
return format_html("<details><summary>Schema</summary>{}</details>", value) return format_html("<details><summary>Schema</summary>{}</details>", value)
+38
View File
@@ -20,7 +20,45 @@
</div> </div>
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>. View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</details> </details>
<form>
<details id="actions-detail" class="mt-3">
<summary>
<strong>Actions</strong>
</summary>
<div class="row mb-2">
<div class="col-auto">
<select id="collection-select" name="collection_id" class="form-select form-select-sm">
<!-- Dynamically load collection options via HTMX -->
<option value="">Loading collections...</option>
<option hx-trigger="every 1s[document.getElementById('actions-detail').open] once" hx-get="{% url 'atlas:collection_options' %}" hx-target="#collection-select" hx-swap="innerHTML"></option>
</select>
</div>
<div class="col-auto">
<button id="add-to-collection-btn"
class="btn btn-sm btn-outline-primary"
hx-post="{% url 'atlas:add_cases_to_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Add selected cases to collection
</button>
<button id="remove-from-collection-btn"
class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:remove_cases_from_collection' %}"
hx-include="[name='selection']:checked, #collection-select"
hx-target="#action-result"
hx-swap="innerHTML"
>
Remove selected cases from collection
</button>
<span id="action-result"></span>
</div>
</div>
</details>
{% render_table table %} {% render_table table %}
</form>
</div> </div>
<div id="exam-options"></div> <div id="exam-options"></div>
@@ -0,0 +1,5 @@
{% for collection in collections %}
<option value="{{ collection.pk }}">{{ collection.name }}</option>
{% empty %}
<option value="">No collections available</option>
{% endfor %}
+1 -39
View File
@@ -21,7 +21,7 @@
<details class="mt-3"> <details class="mt-3">
<summary> <summary>
<strong>Extra options</strong> <strong>Actions</strong>
</summary> </summary>
<form id="bulk-delete-form" method="post" action="{% url 'atlas:series_bulk_delete' %}"> <form id="bulk-delete-form" method="post" action="{% url 'atlas:series_bulk_delete' %}">
{% csrf_token %} {% csrf_token %}
@@ -62,34 +62,6 @@ document.getElementById('bulk-delete-form').addEventListener('submit', function(
{% block js %} {% block js %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Delegate click event to all table rows
document.querySelectorAll('.table tbody tr').forEach(function(row) {
row.addEventListener('click', function(e) {
// Ignore clicks on links or checkboxes
if (e.target.tagName === 'A' || e.target.tagName === 'INPUT') return;
// Find the checkbox in this row
const checkbox = row.querySelector('input[name="selection"]');
if (checkbox) {
checkbox.checked = !checkbox.checked;
row.classList.toggle('selected', checkbox.checked);
}
});
// Keep row visually in sync with checkbox state (e.g. after page reload)
const checkbox = row.querySelector('input[name="selection"]');
if (checkbox && checkbox.checked) {
row.classList.add('selected');
}
// Also toggle row selection when checkbox is clicked directly
if (checkbox) {
checkbox.addEventListener('click', function(e) {
row.classList.toggle('selected', checkbox.checked);
// Prevent row click event from firing
e.stopPropagation();
});
}
});
}); });
</script> </script>
{% endblock %} {% endblock %}
@@ -97,16 +69,6 @@ document.addEventListener('DOMContentLoaded', function() {
{% block css %} {% block css %}
<style> <style>
/* Highlight row on hover */
.table tbody tr:hover {
background-color: darkblue;
cursor: pointer;
}
/* Highlight selected row */
.table tbody tr.selected {
background-color: #b3d7ff !important;
color: #333;
}
</style> </style>
{% endblock css %} {% endblock css %}
+15
View File
@@ -101,6 +101,21 @@ urlpatterns = [
views.collection_history, views.collection_history,
name="collection_history", name="collection_history",
), ),
path(
"collection/add_cases",
views.add_cases_to_collection,
name="add_cases_to_collection",
),
path(
"collection/remove_cases",
views.remove_cases_from_collection,
name="remove_cases_from_collection",
),
path(
"collection/options",
views.collection_options,
name="collection_options",
),
path( path(
"collection/<int:exam_id>/history/<int:user_pk>/user", "collection/<int:exam_id>/history/<int:user_pk>/user",
views.collection_history_user, views.collection_history_user,
+41 -1
View File
@@ -633,6 +633,46 @@ def user_collections(request):
return render(request, "atlas/user_collections.html", {"collections": collections}) return render(request, "atlas/user_collections.html", {"collections": collections})
@login_required
def collection_options(request):
if not request.htmx:
return Http404
collections = CaseCollection.objects.filter(author=request.user).order_by("name")
html = render_to_string("atlas/partials/collection_options.html", {"collections": collections}, request=request)
return HttpResponse(html)
@login_required
def remove_cases_from_collection(request):
if not request.htmx:
return Http404
case_ids = request.POST.getlist('selection')
collection_id = request.POST.get('collection_id')
if not case_ids or not collection_id:
return HttpResponse("No cases selected or collection ID provided.")
collection = get_object_or_404(CaseCollection, pk=collection_id)
if request.user not in collection.author.all():
return HttpResponse("You do not have permission to remove cases from this collection.")
cases = Case.objects.filter(pk__in=case_ids)
for case_ in cases:
collection.cases.remove(case_)
return HttpResponse(f"Removed {len(cases)} cases from collection {collection.name} (refresh to see)")
@login_required
def add_cases_to_collection(request):
if not request.htmx:
return Http404
case_ids = request.POST.getlist('selection')
collection_id = request.POST.get('collection_id')
if not case_ids or not collection_id:
return HttpResponse("No cases selected or collection ID provided.")
collection = get_object_or_404(CaseCollection, pk=collection_id)
if request.user not in collection.author.all():
return HttpResponse("You do not have permission to add cases to this collection.")
cases = Case.objects.filter(pk__in=case_ids)
for case_ in cases:
collection.cases.add(case_)
return HttpResponse(f"Added {len(cases)} cases to collection {collection.name} (refresh to see)")
def add_case_to_collection(request, collection_id): def add_case_to_collection(request, collection_id):
if not request.htmx: if not request.htmx:
@@ -1905,7 +1945,7 @@ def collection_detail(request, pk):
return render( return render(
request, request,
"atlas/collection_detail.html", "atlas/collection_detail.html",
{"collection": collection, "casesdetails": casedetails, "can_edit": True}, {"collection": collection, "casesdetails": casedetails, "can_edit": True, "exam": collection},
) )
@@ -96,22 +96,10 @@ Open access: {{ exam.open_access }}<br />
{% endif %} {% endif %}
<div class="parent-help" title="Click to enable / disable the exam"> {% include "generic/partials/exams/exam_status.html#exam-active" %}
Exam active: <input type="checkbox"
{% if not can_edit %}
disabled
{% endif %}
id="exam-active-switch" {% if exam.active %}checked{% endif %} data-posturl="{% url exam.get_app_name|add:':exam_toggle_active' pk=exam.pk %}"> <span class="help-text">[When checked the exam will be available to take in the test system]</span>
</div>
{% if exam.exam_mode %} {% if exam.exam_mode %}
<div class="parent-help" title="Click to enable / disable the exam results">
Publish results: <input type="checkbox" {% include "generic/partials/exams/exam_status.html#publish-results" %}
{% if not can_edit %}
disabled
{% endif %}
id="exam-publish-results-switch" data-posturl="{% url exam.get_app_name|add:':exam_toggle_results_published' pk=exam.pk %}"
{% if exam.publish_results %}checked{% endif %}> <span class="help-text">[When checked the exam results will be available to users on this site]</span>
</div>
{% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %} {% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %}
<p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p> <p><a href="{% url exam.get_app_name|add:':mark_overview' pk=exam.pk %}"><button>Mark exam</button></a></p>
{% endif %} {% endif %}
@@ -2,14 +2,14 @@
{% partialdef publish-results %} {% partialdef publish-results %}
<div id="publish-results-status" > <div id="publish-results-status" >
Publish results: <span>{{collection.publish_results}}</span> Publish results: <span>{{exam.publish_results}}</span>
<button <button
hx-post="{% url 'atlas:exam_toggle_results_published' collection.pk %}" hx-post="{% url exam.get_app_name|add:':exam_toggle_results_published' exam.pk %}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#publish-results-status" hx-target="#publish-results-status"
class="btn btn-sm btn-outline-primary reduce-opacity" class="btn btn-sm btn-outline-primary reduce-opacity"
> >
{% if collection.publish_results %}Unpublish Results{% else %}Publish Results{% endif %} {% if exam.publish_results %}Unpublish Results{% else %}Publish Results{% endif %}
</button> </button>
</div> </div>
{% endpartialdef publish-results %} {% endpartialdef publish-results %}
@@ -17,14 +17,14 @@
{% partialdef exam-active %} {% partialdef exam-active %}
<div id="exam-active-status"> <div id="exam-active-status">
Exam active: <span>{{collection.active}}</span> Exam active: <span>{{exam.active}}</span>
<button <button
hx-post="{% url 'atlas:exam_toggle_active' collection.pk %}" hx-post="{% url exam.get_app_name|add:':exam_toggle_active' exam.pk %}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#exam-active-status" hx-target="#exam-active-status"
class="btn btn-sm btn-outline-primary reduce-opacity" class="btn btn-sm btn-outline-primary reduce-opacity"
> >
{% if collection.active %}Deactivate Exam{% else %}Activate Exam{% endif %} {% if exam.active %}Deactivate Exam{% else %}Activate Exam{% endif %}
</button> </button>
</div> </div>
{% endpartialdef exam-active %} {% endpartialdef exam-active %}
+2 -2
View File
@@ -388,12 +388,12 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
), ),
path( path(
"exam/<int:pk>/toggle_active", "exam/<int:pk>/toggle_active",
generic_exam_view.exam_toggle_active, generic_exam_view.exam_toggle_active_htmx,
name="exam_toggle_active", name="exam_toggle_active",
), ),
path( path(
"exam/<int:pk>/toggle_results_published", "exam/<int:pk>/toggle_results_published",
generic_exam_view.exam_toggle_results_published, generic_exam_view.exam_toggle_results_published_htmx,
name="exam_toggle_results_published", name="exam_toggle_results_published",
), ),
path( path(
+41
View File
@@ -2,6 +2,7 @@ import base64
from collections import defaultdict from collections import defaultdict
import hashlib import hashlib
import mimetypes import mimetypes
from pydicom.uid import ExplicitVRLittleEndian
from io import BytesIO from io import BytesIO
@@ -19,6 +20,7 @@ try:
from .pydicom_PIL import get_PIL_image from .pydicom_PIL import get_PIL_image
except: except:
from pydicom_PIL import get_PIL_image from pydicom_PIL import get_PIL_image
import numpy as np
def image_as_base64(image_file): def image_as_base64(image_file):
""" """
@@ -284,3 +286,42 @@ def compare_dicom_datasets(*datasets: pydicom.Dataset):
return differences return differences
def combine_dicom_images_side_by_side(dicom_path1, dicom_path2):
"""
Combines two DICOM images side by side, using the metadata from the first image.
If the heights differ, pads the shorter image with zeros at the bottom.
Returns a new pydicom.Dataset with the combined image.
"""
ds1 = pydicom.dcmread(dicom_path1)
ds2 = pydicom.dcmread(dicom_path2)
arr1 = ds1.pixel_array
arr2 = ds2.pixel_array
h1, w1 = arr1.shape[:2]
h2, w2 = arr2.shape[:2]
# Pad arrays if heights differ
if h1 != h2:
max_h = max(h1, h2)
def pad(arr, target_h):
pad_height = target_h - arr.shape[0]
if pad_height > 0:
pad_shape = ((0, pad_height), (0, 0)) if arr.ndim == 2 else ((0, pad_height), (0, 0), (0, 0))
arr = np.pad(arr, pad_shape, mode='constant', constant_values=0)
return arr
arr1 = pad(arr1, max_h)
arr2 = pad(arr2, max_h)
combined = np.hstack((arr1, arr2))
ds1.PixelData = combined.tobytes()
ds1.Rows, ds1.Columns = combined.shape[:2]
if hasattr(ds1, 'PhotometricInterpretation'):
ds1.PhotometricInterpretation = ds1.PhotometricInterpretation
# Ensure output is uncompressed
ds1.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
return ds1
+13
View File
@@ -1396,3 +1396,16 @@ span#user-id {
.btn.reduce-opacity:hover, .btn.reduce-opacity:focus { .btn.reduce-opacity:hover, .btn.reduce-opacity:focus {
opacity: 1; opacity: 1;
} }
/* Highlight row on hover */
.row-selector tbody tr:hover td {
background-color: darkblue;
cursor: pointer;
}
/* Highlight selected row */
.row-selector tbody tr.selected td {
background-color: #b3d7ff !important;
color: #333;
}
+1
View File
@@ -435,3 +435,4 @@ def exam_clone2(request, exam_id):
exam = get_object_or_404(Exam, pk=exam_id) exam = get_object_or_404(Exam, pk=exam_id)
new_exam = exam.clone_model() new_exam = exam.clone_model()
return redirect("sbas:exam_update", pk=new_exam.id) return redirect("sbas:exam_update", pk=new_exam.id)
+2 -1
View File
@@ -2,11 +2,12 @@ from django.contrib import admin
# Register your models here. # Register your models here.
from .models import Question, SampleAnswer, Exam, ExamUserStatus, UserAnswer from .models import Question, SampleAnswer, Exam, ExamUserStatus, UserAnswer, QuestionImage
admin.site.register(Question) admin.site.register(Question)
admin.site.register(SampleAnswer) admin.site.register(SampleAnswer)
admin.site.register(UserAnswer) admin.site.register(UserAnswer)
admin.site.register(QuestionImage)
#class RapidImageInline(admin.TabularInline): #class RapidImageInline(admin.TabularInline):
# model = QuestionImage # model = QuestionImage
+46 -1
View File
@@ -31,6 +31,9 @@ from rapids.models import Abnormality, Examination, Region
from helpers.images import get_image_hash, image_as_base64 from helpers.images import get_image_hash, image_as_base64
from django.utils.html import format_html from django.utils.html import format_html
from helpers.images import combine_dicom_images_side_by_side
from django.core.files import File
import tempfile
def image_directory_path(instance, filename): def image_directory_path(instance, filename):
@@ -108,6 +111,43 @@ class Question(QuestionBase):
except pydicom.errors.InvalidDicomError: except pydicom.errors.InvalidDicomError:
pass pass
def combine_images_side_by_side(self, image1_id, image2_id):
"""
Combines two DICOM images side by side and saves the new image.
Marks the original images as feedback images.
"""
img1 = self.images.get(id=image1_id)
img2 = self.images.get(id=image2_id)
dicom_path1 = os.path.join(settings.MEDIA_ROOT, img1.image.name)
dicom_path2 = os.path.join(settings.MEDIA_ROOT, img2.image.name)
# Combine images (returns a FileDataset)
combined_dataset = combine_dicom_images_side_by_side(dicom_path1, dicom_path2)
# Save new image directly to storage
combined_image_name = f"combined_{img1.filename}_{img2.filename}.dcm"
combined_image_path = os.path.join("shorts/picture", combined_image_name)
full_path = os.path.join(settings.MEDIA_ROOT, combined_image_path)
combined_dataset.save_as(full_path)
with open(full_path, "rb") as f:
new_image = QuestionImage(
question=self,
image=File(f, name=combined_image_path),
feedback_image=False,
is_dicom=True,
)
new_image.save()
# Mark originals as feedback images
img1.feedback_image = True
img1.save()
img2.feedback_image = True
img2.save()
return new_image
def check_user_can_edit(self, user): def check_user_can_edit(self, user):
if user.is_superuser: if user.is_superuser:
return True return True
@@ -170,6 +210,11 @@ class Question(QuestionBase):
return user_answers return user_answers
def get_examination_string(self):
"""Returns a string of the examinations associated with the question."""
examinations = self.examination.all().values_list("examination", flat=True)
return ", ".join(examinations)
class QuestionImage(models.Model): class QuestionImage(models.Model):
question = models.ForeignKey( question = models.ForeignKey(
@@ -358,7 +403,7 @@ class Exam(ExamBase):
if i.description: if i.description:
image_titles.append(i.description) image_titles.append(i.description)
else: else:
image_titles.append("") image_titles.append(q.get_examination_string())
exam_questions[q.id] = { exam_questions[q.id] = {
"images": images, "images": images,
@@ -0,0 +1 @@
<div class="alert alert-warning">{{ error }}</div>
@@ -0,0 +1,19 @@
<span class="image-block">
Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}:
<div class="dicom-image shorts-img {% if image.feedback_image %}feedback-img{% endif %}"
data-url="{{ remote_url }}{{ image.image.url}}"></div>
</span>
<script>
document.body.addEventListener("htmx:afterSwap", function(evt) {
const dataUrl = "{{ remote_url }}{{ image.image.url}}";
console.log(dataUrl);
const dicomDiv = document.querySelector('.dicom-image[data-url="' + dataUrl + '"]');
console.log(dicomDiv);
if (dicomDiv) {
setUpDicom(dicomDiv);
}
});
</script>
@@ -18,13 +18,56 @@
</p> </p>
<p class="pre-whitespace"><b>Abnormality:</b> {{ question.get_abnormalities }}</p> <p class="pre-whitespace"><b>Abnormality:</b> {{ question.get_abnormalities }}</p>
<div class="pre-whitespace multi-image-block"><b>Images:</b> <div class="pre-whitespace multi-image-block"><b>Images:</b>
<form>
{% for image in question.images.all %} {% for image in question.images.all %}
<span class="image-block"> <span class="image-block" style="display: inline-block; vertical-align: top; margin-right: 10px;">
Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}: Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}:
<div class="dicom-image shorts-img {% if image.feedback_image %}feedback-img{% endif %}" <div class="dicom-image shorts-img {% if image.feedback_image %}feedback-img{% endif %}"
data-url="{{ remote_url }}{{ image.image.url}}"></div> data-url="{{ remote_url }}{{ image.image.url}}"></div>
<input type="checkbox" class="combine-image-checkbox hide" name="combine-image-checkbox" value="{{ image.pk }}">
</span> </span>
{% endfor %} {% endfor %}
<details id="combine-images-details">
<summary>Combine Images</summary>
<button id="combine-images-btn"
class="btn btn-sm btn-outline-primary"
hx-post="{% url 'shorts:combine_images_side_by_side' question.pk %}"
hx-include=".combine-image-checkbox:checked"
hx-target="#combine-images-result"
hx-swap="innerHTML"
hx-indicator="#combine-images-progress">
Combine Selected Images Side by Side
</button>
<div id="combine-images-progress" class="htmx-indicator">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Combining images, please wait...
</div>
<div id="combine-images-result"></div>
</details>
<script>
document.addEventListener("DOMContentLoaded", function() {
const checkboxBlocks = document.querySelectorAll(".combine-image-checkbox");
const details = document.getElementById("combine-images-details");
console.log("checkboxBlocks", checkboxBlocks);
function updateVisibility() {
if (details.open) {
checkboxBlocks.forEach(cb => cb.style.display = "inline-block");
// Auto-select if only two checkboxes
if (checkboxBlocks.length === 2) {
checkboxBlocks.forEach(cb => cb.checked = true);
}
} else {
checkboxBlocks.forEach(cb => cb.style.display = "none");
}
}
if (details) {
updateVisibility();
details.addEventListener("toggle", updateVisibility);
}
});
</script>
</form>
</div> </div>
<a href='{% url "shorts:question_findings" question_id=question.pk %}'>Click here to view/add findings</a> <a href='{% url "shorts:question_findings" question_id=question.pk %}'>Click here to view/add findings</a>
<div> <div>
+1
View File
@@ -46,6 +46,7 @@ urlpatterns = [
views.question_add_exam, views.question_add_exam,
name="question_add_exam", name="question_add_exam",
), ),
path('question/<int:question_id>/combine-images/', views.combine_images_side_by_side, name='combine_images_side_by_side'),
#path("answer/<int:answer_id>/confirm", views.confirm_answer, name="confirm_answer"), #path("answer/<int:answer_id>/confirm", views.confirm_answer, name="confirm_answer"),
#path("answer/<int:answer_id>/delete", views.delete_answer, name="delete_answer"), #path("answer/<int:answer_id>/delete", views.delete_answer, name="delete_answer"),
path( path(
+13
View File
@@ -1,6 +1,7 @@
import json import json
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django import forms from django import forms
from django.views.decorators.http import require_POST
# from django.contrib.auth.models import User # from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
@@ -42,6 +43,7 @@ from generic.views import (
) )
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from atlas.models import Finding, Structure, Condition from atlas.models import Finding, Structure, Condition
from rad import settings
from shorts.decorators import user_is_author_or_shorts_checker from shorts.decorators import user_is_author_or_shorts_checker
from .models import ( from .models import (
@@ -949,3 +951,14 @@ def question_findings(request, question_id, finding_pk=None):
}, },
) )
@require_POST
def combine_images_side_by_side(request, question_id):
image_ids = request.POST.getlist('combine-image-checkbox')
print(image_ids)
if len(image_ids) != 2:
return render(request, "shorts/combine_error.html", {"error": "Please select exactly two images."})
question = get_object_or_404(Question, pk=question_id)
image = question.combine_images_side_by_side(*image_ids)
return render(request, "shorts/combine_result.html", {"image": image,
"remote_url": settings.REMOTE_URL,
})