diff --git a/anatomy/static/js/anatomy.js b/anatomy/static/js/anatomy.js index 25e7132a..579a101f 100644 --- a/anatomy/static/js/anatomy.js +++ b/anatomy/static/js/anatomy.js @@ -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'); @@ -482,6 +513,8 @@ async function setUpDicom(element) { } +window.setUpDicom = setUpDicom; + //function keyUpHandler(e) { // if (e.key == "Control") { // dicomViewer.registerPrimaryDicomInterface(e); @@ -681,4 +714,5 @@ function initializeClock(id, endtime) { updateClock(); } -window.initializeClock = initializeClock \ No newline at end of file +window.initializeClock = initializeClock + diff --git a/atlas/tables.py b/atlas/tables.py index f39e1156..e5976728 100755 --- a/atlas/tables.py +++ b/atlas/tables.py @@ -183,6 +183,8 @@ class CaseTable(tables.Table): ) sequence = ("view", ) order_by = "-created_date" + attrs = {"class": "table row-selector"} + class PopupLinkColumn(tables.Column): @@ -231,6 +233,8 @@ class SeriesTable(tables.Table): ) sequence = ("view", "popup", "images", "case") order_by = "-created_date" + attrs = {"class": "table row-selector"} + def __init__(self, data=None, *args, **kwargs): super().__init__( @@ -271,6 +275,8 @@ class ConditionTable(tables.Table): template_name = "django_tables2/bootstrap4.html" fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum") sequence = ("name",) + attrs = {"class": "table row-selector"} + def __init__(self, data=None, *args, **kwargs): super().__init__( @@ -307,6 +313,8 @@ class FindingTable(tables.Table): template_name = "django_tables2/bootstrap4.html" fields = ("name", "primary") sequence = ("name",) + attrs = {"class": "table row-selector"} + def render_synonym(self, value, record): return format_html(record.get_synonym_link()) @@ -334,6 +342,8 @@ class StructureTable(tables.Table): template_name = "django_tables2/bootstrap4.html" fields = ("name", "primary") sequence = ("name",) + attrs = {"class": "table row-selector"} + def render_synonym(self, value, record): return format_html(record.get_synonym_link()) @@ -360,6 +370,8 @@ class PresentationTable(tables.Table): template_name = "django_tables2/bootstrap4.html" fields = ("name",) sequence = ("name",) + attrs = {"class": "table row-selector"} + class PathologicalProcessTable(tables.Table): @@ -381,6 +393,8 @@ class PathologicalProcessTable(tables.Table): template_name = "django_tables2/bootstrap4.html" fields = ("name",) sequence = ("name",) + attrs = {"class": "table row-selector"} + class SubspecialtyTable(tables.Table): @@ -402,6 +416,8 @@ class SubspecialtyTable(tables.Table): template_name = "django_tables2/bootstrap4.html" fields = () sequence = ("name",) + attrs = {"class": "table row-selector"} + def render_synonym(self, value, record): return format_html(record.get_synonym_link()) @@ -436,6 +452,8 @@ class CaseCollectionTable(tables.Table): "author", ) sequence = ("view", )#, "series") + attrs = {"class": "table row-selector"} + def __init__(self, data=None, *args, **kwargs): super().__init__( @@ -463,6 +481,8 @@ class QuestionSchemaTable(tables.Table): model = QuestionSchema template_name = "django_tables2/bootstrap4.html" fields = ("name", "description", "schema") + attrs = {"class": "table row-selector"} + def render_schema(self, value, record): return format_html("
Schema{}
", value) \ No newline at end of file diff --git a/atlas/templates/atlas/case_view.html b/atlas/templates/atlas/case_view.html index 41ecf0cf..1c8e9c66 100755 --- a/atlas/templates/atlas/case_view.html +++ b/atlas/templates/atlas/case_view.html @@ -20,7 +20,45 @@ View my cases. + +
+
+ + Actions + +
+
+ +
+
+ + + +
+
+
{% render_table table %} +
diff --git a/atlas/templates/atlas/partials/collection_options.html b/atlas/templates/atlas/partials/collection_options.html new file mode 100644 index 00000000..9e2f125d --- /dev/null +++ b/atlas/templates/atlas/partials/collection_options.html @@ -0,0 +1,5 @@ + {% for collection in collections %} + + {% empty %} + + {% endfor %} \ No newline at end of file diff --git a/atlas/templates/atlas/series_view.html b/atlas/templates/atlas/series_view.html index 5041a75a..87c150ca 100755 --- a/atlas/templates/atlas/series_view.html +++ b/atlas/templates/atlas/series_view.html @@ -21,7 +21,7 @@
- Extra options + Actions
{% csrf_token %} @@ -62,34 +62,6 @@ document.getElementById('bulk-delete-form').addEventListener('submit', function( {% block js %} {% endblock %} @@ -97,16 +69,6 @@ document.addEventListener('DOMContentLoaded', function() { {% block css %} {% endblock css %} \ No newline at end of file diff --git a/atlas/urls.py b/atlas/urls.py index c4a54d5e..5c37a43f 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -101,6 +101,21 @@ urlpatterns = [ views.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( "collection//history//user", views.collection_history_user, diff --git a/atlas/views.py b/atlas/views.py index 51281006..ce4fab4c 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -633,6 +633,46 @@ def user_collections(request): 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): if not request.htmx: @@ -1905,7 +1945,7 @@ def collection_detail(request, pk): return render( request, "atlas/collection_detail.html", - {"collection": collection, "casesdetails": casedetails, "can_edit": True}, + {"collection": collection, "casesdetails": casedetails, "can_edit": True, "exam": collection}, ) diff --git a/generic/templates/generic/exam_overview_headers.html b/generic/templates/generic/exam_overview_headers.html index 2da24226..5a8952fb 100644 --- a/generic/templates/generic/exam_overview_headers.html +++ b/generic/templates/generic/exam_overview_headers.html @@ -96,22 +96,10 @@ Open access: {{ exam.open_access }}
{% endif %} -
- Exam active: [When checked the exam will be available to take in the test system] -
+{% include "generic/partials/exams/exam_status.html#exam-active" %} {% if exam.exam_mode %} -
- Publish results: [When checked the exam results will be available to users on this site] -
+ + {% include "generic/partials/exams/exam_status.html#publish-results" %} {% if exam.get_app_name == "anatomy" or exam.get_app_name == "longs" %}

{% endif %} diff --git a/generic/templates/generic/partials/exams/exam_status.html b/generic/templates/generic/partials/exams/exam_status.html index f2d4831b..7a130cfd 100644 --- a/generic/templates/generic/partials/exams/exam_status.html +++ b/generic/templates/generic/partials/exams/exam_status.html @@ -1,15 +1,15 @@ {% load partials %} {% partialdef publish-results %} -
- Publish results: {{collection.publish_results}} +
+ Publish results: {{exam.publish_results}}
{% endpartialdef publish-results %} @@ -17,14 +17,14 @@ {% partialdef exam-active %}
- Exam active: {{collection.active}} + Exam active: {{exam.active}}
{% endpartialdef exam-active %} \ No newline at end of file diff --git a/generic/urls.py b/generic/urls.py index 5efa64b7..5aa73aa8 100755 --- a/generic/urls.py +++ b/generic/urls.py @@ -388,12 +388,12 @@ def generic_exam_urls(generic_exam_view: GenericExamViews): ), path( "exam//toggle_active", - generic_exam_view.exam_toggle_active, + generic_exam_view.exam_toggle_active_htmx, name="exam_toggle_active", ), path( "exam//toggle_results_published", - generic_exam_view.exam_toggle_results_published, + generic_exam_view.exam_toggle_results_published_htmx, name="exam_toggle_results_published", ), path( diff --git a/helpers/images.py b/helpers/images.py index 9dbcd5eb..27f01761 100644 --- a/helpers/images.py +++ b/helpers/images.py @@ -2,6 +2,7 @@ import base64 from collections import defaultdict import hashlib import mimetypes +from pydicom.uid import ExplicitVRLittleEndian from io import BytesIO @@ -19,6 +20,7 @@ try: from .pydicom_PIL import get_PIL_image except: from pydicom_PIL import get_PIL_image +import numpy as np def image_as_base64(image_file): """ @@ -284,3 +286,42 @@ def compare_dicom_datasets(*datasets: pydicom.Dataset): 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 diff --git a/rad/static/css/anatomy.css b/rad/static/css/anatomy.css index 24909617..ace6bc26 100644 --- a/rad/static/css/anatomy.css +++ b/rad/static/css/anatomy.css @@ -1395,4 +1395,17 @@ span#user-id { } .btn.reduce-opacity:hover, .btn.reduce-opacity:focus { 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; } \ No newline at end of file diff --git a/sbas/views.py b/sbas/views.py index 5185f949..52379f92 100644 --- a/sbas/views.py +++ b/sbas/views.py @@ -435,3 +435,4 @@ def exam_clone2(request, exam_id): exam = get_object_or_404(Exam, pk=exam_id) new_exam = exam.clone_model() return redirect("sbas:exam_update", pk=new_exam.id) + diff --git a/shorts/admin.py b/shorts/admin.py index 2165b8fc..57e04efe 100644 --- a/shorts/admin.py +++ b/shorts/admin.py @@ -2,11 +2,12 @@ from django.contrib import admin # 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(SampleAnswer) admin.site.register(UserAnswer) +admin.site.register(QuestionImage) #class RapidImageInline(admin.TabularInline): # model = QuestionImage diff --git a/shorts/models.py b/shorts/models.py index 1ec2abdd..ccf68f14 100644 --- a/shorts/models.py +++ b/shorts/models.py @@ -31,6 +31,9 @@ from rapids.models import Abnormality, Examination, Region from helpers.images import get_image_hash, image_as_base64 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): @@ -108,6 +111,43 @@ class Question(QuestionBase): except pydicom.errors.InvalidDicomError: 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): if user.is_superuser: return True @@ -169,6 +209,11 @@ class Question(QuestionBase): user_answers = set([i.answer for i in queryset]) 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): @@ -358,7 +403,7 @@ class Exam(ExamBase): if i.description: image_titles.append(i.description) else: - image_titles.append("") + image_titles.append(q.get_examination_string()) exam_questions[q.id] = { "images": images, diff --git a/shorts/templates/shorts/combine_error.html b/shorts/templates/shorts/combine_error.html new file mode 100644 index 00000000..4497d87c --- /dev/null +++ b/shorts/templates/shorts/combine_error.html @@ -0,0 +1 @@ +
{{ error }}
\ No newline at end of file diff --git a/shorts/templates/shorts/combine_result.html b/shorts/templates/shorts/combine_result.html new file mode 100644 index 00000000..e383e78a --- /dev/null +++ b/shorts/templates/shorts/combine_result.html @@ -0,0 +1,19 @@ + + + + Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}: +
+
+ + \ No newline at end of file diff --git a/shorts/templates/shorts/question_display_block.html b/shorts/templates/shorts/question_display_block.html index fc3eb33a..743030ad 100755 --- a/shorts/templates/shorts/question_display_block.html +++ b/shorts/templates/shorts/question_display_block.html @@ -18,23 +18,66 @@

Abnormality: {{ question.get_abnormalities }}

Images: - {% for image in question.images.all %} - - Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}: -
-
- {% endfor %} + + {% for image in question.images.all %} + + Image {{ forloop.counter }}{% if image.description %} ({{image.description}}){% endif %}{% if image.feedback_image %} [feedback image]{% endif %}: +
+ +
+ {% endfor %} + +
+ Combine Images + +
+ + Combining images, please wait... +
+
+
+ +
Click here to view/add findings
Exam(s): {% for exam in question.exams.all %} {{ exam }}, {% endfor %} - - + +

Open Access: {{ question.open_access }}

@@ -56,8 +99,8 @@ {{ answer }} - {{answer.score}} - + {{answer.score}} + {% endfor %}
@@ -119,7 +162,7 @@ json_toolstates.push(JSON.stringify(toolstate[image])); } else if ("wadouri:" + image in toolstate) { json_toolstates.push(JSON.stringify(toolstate["wadouri:" + image])); - } + } } console.log("json_toolstates", json_toolstates) diff --git a/shorts/urls.py b/shorts/urls.py index 74f27aaa..a2be0009 100644 --- a/shorts/urls.py +++ b/shorts/urls.py @@ -46,6 +46,7 @@ urlpatterns = [ views.question_add_exam, name="question_add_exam", ), + path('question//combine-images/', views.combine_images_side_by_side, name='combine_images_side_by_side'), #path("answer//confirm", views.confirm_answer, name="confirm_answer"), #path("answer//delete", views.delete_answer, name="delete_answer"), path( diff --git a/shorts/views.py b/shorts/views.py index c6ebd813..5034adef 100644 --- a/shorts/views.py +++ b/shorts/views.py @@ -1,6 +1,7 @@ import json from django.shortcuts import render, get_object_or_404, redirect from django import forms +from django.views.decorators.http import require_POST # from django.contrib.auth.models import User 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 atlas.models import Finding, Structure, Condition +from rad import settings from shorts.decorators import user_is_author_or_shorts_checker 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, + }) \ No newline at end of file