Too many changes :(
This commit is contained in:
@@ -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);
|
||||
@@ -682,3 +715,4 @@ function initializeClock(id, endtime) {
|
||||
}
|
||||
|
||||
window.initializeClock = initializeClock
|
||||
|
||||
|
||||
@@ -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("<details><summary>Schema</summary>{}</details>", value)
|
||||
@@ -20,7 +20,45 @@
|
||||
</div>
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||
</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 %}
|
||||
</form>
|
||||
</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 %}
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<details class="mt-3">
|
||||
<summary>
|
||||
<strong>Extra options</strong>
|
||||
<strong>Actions</strong>
|
||||
</summary>
|
||||
<form id="bulk-delete-form" method="post" action="{% url 'atlas:series_bulk_delete' %}">
|
||||
{% csrf_token %}
|
||||
@@ -62,34 +62,6 @@ document.getElementById('bulk-delete-form').addEventListener('submit', function(
|
||||
{% block js %}
|
||||
<script>
|
||||
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>
|
||||
{% endblock %}
|
||||
@@ -97,16 +69,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
{% block css %}
|
||||
<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>
|
||||
{% endblock css %}
|
||||
|
||||
@@ -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/<int:exam_id>/history/<int:user_pk>/user",
|
||||
views.collection_history_user,
|
||||
|
||||
+41
-1
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -96,22 +96,10 @@ Open access: {{ exam.open_access }}<br />
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="parent-help" title="Click to enable / disable the exam">
|
||||
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>
|
||||
{% include "generic/partials/exams/exam_status.html#exam-active" %}
|
||||
{% if exam.exam_mode %}
|
||||
<div class="parent-help" title="Click to enable / disable the exam results">
|
||||
Publish results: <input type="checkbox"
|
||||
{% 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>
|
||||
|
||||
{% include "generic/partials/exams/exam_status.html#publish-results" %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef publish-results %}
|
||||
<div id="publish-results-status">
|
||||
Publish results: <span>{{collection.publish_results}}</span>
|
||||
<div id="publish-results-status" >
|
||||
Publish results: <span>{{exam.publish_results}}</span>
|
||||
<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-target="#publish-results-status"
|
||||
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>
|
||||
</div>
|
||||
{% endpartialdef publish-results %}
|
||||
@@ -17,14 +17,14 @@
|
||||
|
||||
{% partialdef exam-active %}
|
||||
<div id="exam-active-status">
|
||||
Exam active: <span>{{collection.active}}</span>
|
||||
Exam active: <span>{{exam.active}}</span>
|
||||
<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-target="#exam-active-status"
|
||||
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>
|
||||
</div>
|
||||
{% endpartialdef exam-active %}
|
||||
+2
-2
@@ -388,12 +388,12 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
||||
),
|
||||
path(
|
||||
"exam/<int:pk>/toggle_active",
|
||||
generic_exam_view.exam_toggle_active,
|
||||
generic_exam_view.exam_toggle_active_htmx,
|
||||
name="exam_toggle_active",
|
||||
),
|
||||
path(
|
||||
"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",
|
||||
),
|
||||
path(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1396,3 +1396,16 @@ 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+46
-1
@@ -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
|
||||
@@ -170,6 +210,11 @@ class Question(QuestionBase):
|
||||
|
||||
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):
|
||||
question = models.ForeignKey(
|
||||
@@ -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,
|
||||
|
||||
@@ -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 class="pre-whitespace"><b>Abnormality:</b> {{ question.get_abnormalities }}</p>
|
||||
<div class="pre-whitespace multi-image-block"><b>Images:</b>
|
||||
<form>
|
||||
{% 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 %}:
|
||||
<div class="dicom-image shorts-img {% if image.feedback_image %}feedback-img{% endif %}"
|
||||
data-url="{{ remote_url }}{{ image.image.url}}"></div>
|
||||
<input type="checkbox" class="combine-image-checkbox hide" name="combine-image-checkbox" value="{{ image.pk }}">
|
||||
</span>
|
||||
{% 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>
|
||||
<a href='{% url "shorts:question_findings" question_id=question.pk %}'>Click here to view/add findings</a>
|
||||
<div>
|
||||
|
||||
@@ -46,6 +46,7 @@ urlpatterns = [
|
||||
views.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>/delete", views.delete_answer, name="delete_answer"),
|
||||
path(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user