improve findings
This commit is contained in:
@@ -12,6 +12,10 @@ window.control_pressed = false;
|
||||
|
||||
|
||||
$(document).ready(function () {
|
||||
// Select all text input fields and textareas with the clear button
|
||||
clearableTextAreaSetup();
|
||||
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function(e) {
|
||||
let el = document.getElementById("htmx-error");
|
||||
var error;
|
||||
@@ -193,6 +197,40 @@ window.addEventListener('loadDicomViewerUrls', function (e) {
|
||||
loadDicomViewer(e.detail.images, e.detail.annotations);
|
||||
}, false);
|
||||
|
||||
function clearableTextAreaSetup() {
|
||||
const textFields = document.querySelectorAll(".position-relative input[type='text'], .position-relative textarea");
|
||||
|
||||
textFields.forEach((field) => {
|
||||
const wrapper = field.closest(".position-relative");
|
||||
const clearButton = wrapper.querySelector(".clear-input-button");
|
||||
|
||||
// Function to toggle the visibility of the clear button
|
||||
const toggleClearButton = () => {
|
||||
if (field.value.trim() !== "" && wrapper.matches(":hover")) {
|
||||
clearButton.style.display = "inline-block"; // Show the button
|
||||
} else {
|
||||
clearButton.style.display = "none"; // Hide the button
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
toggleClearButton();
|
||||
|
||||
// Add event listeners to monitor changes in the input field or textarea
|
||||
field.addEventListener("input", toggleClearButton);
|
||||
|
||||
// Add hover event listeners to the wrapper
|
||||
wrapper.addEventListener("mouseenter", toggleClearButton);
|
||||
wrapper.addEventListener("mouseleave", toggleClearButton);
|
||||
|
||||
// Add click event to clear the field or textarea
|
||||
clearButton.addEventListener("click", function () {
|
||||
field.value = ""; // Clear the field
|
||||
toggleClearButton();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadDicomViewer(images_to_load, annotations_to_load) {
|
||||
console.log("loadDicomViewer", images_to_load);
|
||||
let single_dicom = document.getElementById("single-dicom-viewer");
|
||||
|
||||
+18
-8
@@ -49,7 +49,7 @@ from generic.models import Examination#, Sign
|
||||
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div
|
||||
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
|
||||
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
@@ -71,6 +71,12 @@ from typing import Any
|
||||
from helpers.cimar import CimarAPI, NotFoundError
|
||||
from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD
|
||||
|
||||
class ClearableInput(TextInput):
|
||||
template_name = "crispy_forms/widgets/clearable_input_single_line.html"
|
||||
|
||||
class ClearableTextarea(Textarea):
|
||||
template_name = "crispy_forms/widgets/clearable_input.html"
|
||||
|
||||
class CaseSelect(Select):
|
||||
template_name = "atlas/case_select_widget.html"
|
||||
|
||||
@@ -295,14 +301,16 @@ class SeriesForm(ModelForm):
|
||||
|
||||
class CaseForm(ModelForm):
|
||||
|
||||
# exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False))
|
||||
|
||||
|
||||
class Media:
|
||||
# Django also includes a few javascript files necessary
|
||||
# for the operation of this form element. You need to
|
||||
# include <script src="/admin/jsi18n"></script>
|
||||
# in the template.
|
||||
#history = forms.CharField(
|
||||
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
||||
# required=False,
|
||||
#)
|
||||
|
||||
css = {
|
||||
"all": ["css/widgets.css"],
|
||||
}
|
||||
@@ -367,7 +375,7 @@ class CaseForm(ModelForm):
|
||||
"open_access",
|
||||
"previous_case",
|
||||
"diagnostic_certainty",
|
||||
"cimar_uuid"
|
||||
"cimar_uuid",
|
||||
),
|
||||
|
||||
)
|
||||
@@ -410,9 +418,11 @@ class CaseForm(ModelForm):
|
||||
# (False, 'No')])
|
||||
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
|
||||
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
|
||||
"description": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
"history": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
"discussion": Textarea(attrs={"cols": 80, "rows": 5}),
|
||||
"title": ClearableInput(attrs={"rows": 1}),
|
||||
"description": ClearableTextarea(attrs={"cols": 80, "rows": 5}),
|
||||
"history": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
||||
"discussion": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
||||
"report": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
||||
"condition": autocomplete.ModelSelect2Multiple(
|
||||
url="atlas:condition-autocomplete"
|
||||
),
|
||||
|
||||
@@ -774,6 +774,21 @@ class Series(SeriesBase):
|
||||
def get_link_headers(self):
|
||||
return "atlas/series_headers.html"
|
||||
|
||||
def get_related_findings(self):
|
||||
"""Returns the related findings for the series, including findings from other series in the same case."""
|
||||
# Get findings directly related to this series
|
||||
findings = list(self.findings.all())
|
||||
|
||||
# Get findings from other series in the same case
|
||||
if hasattr(self, "case") and self.case.exists():
|
||||
for case in self.case.all():
|
||||
for other_series in case.series.exclude(pk=self.pk):
|
||||
findings.extend(other_series.findings.all())
|
||||
|
||||
# Remove duplicates (by pk)
|
||||
unique_findings = {f.pk: f for f in findings}.values()
|
||||
return list(unique_findings)
|
||||
|
||||
|
||||
|
||||
class CaseCollection(ExamOrCollectionGenericBase):
|
||||
|
||||
@@ -214,8 +214,6 @@
|
||||
>
|
||||
<span>
|
||||
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}"><button class="btn btn-primary btn-sm">View</button></a>
|
||||
<a target="_blank" href="{{series.get_absolute_url}}?show_finding={{finding.pk}}"><button class="btn btn-primary btn-sm">Popup</button></a>
|
||||
|
||||
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
|
||||
View in Modal
|
||||
</button>
|
||||
|
||||
@@ -51,8 +51,70 @@
|
||||
$('#add_more_resource').click(() => { add_resource_input_form() });
|
||||
})
|
||||
|
||||
</script>
|
||||
{% comment %} document.addEventListener("DOMContentLoaded", function () {
|
||||
// Select all text input fields and textareas
|
||||
const textFields = document.querySelectorAll("input[type='text'], textarea");
|
||||
|
||||
textFields.forEach((field) => {
|
||||
// Create a clear button for each text field or textarea
|
||||
const clearButton = document.createElement("button");
|
||||
clearButton.type = "button";
|
||||
clearButton.className = "btn btn-danger btn-sm clear-input-button";
|
||||
clearButton.textContent = "×";
|
||||
|
||||
// Wrap the input field or textarea in a container with position-relative
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "position-relative";
|
||||
field.parentNode.insertBefore(wrapper, field);
|
||||
wrapper.appendChild(field);
|
||||
wrapper.appendChild(clearButton);
|
||||
|
||||
// Function to toggle the visibility of the clear button
|
||||
const toggleClearButton = () => {
|
||||
if (field.value.trim() !== "") {
|
||||
clearButton.classList.add("show"); // Add the "show" class
|
||||
} else {
|
||||
clearButton.classList.remove("show"); // Remove the "show" class
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
toggleClearButton();
|
||||
|
||||
// Add event listeners to monitor changes in the input field or textarea
|
||||
field.addEventListener("input", toggleClearButton);
|
||||
|
||||
// Add click event to clear the field or textarea
|
||||
clearButton.addEventListener("click", function () {
|
||||
field.value = ""; // Clear the field
|
||||
toggleClearButton(); // Update the button visibility
|
||||
});
|
||||
});
|
||||
});
|
||||
{% endcomment %}
|
||||
</script>
|
||||
{% comment %} <style>
|
||||
/* Ensure the parent container is positioned relatively */
|
||||
.position-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Style the clear button */
|
||||
.clear-input-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none; /* Hidden by default */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Show the clear button only when the input field or textarea is hovered and contains text */
|
||||
.position-relative:hover .clear-input-button.show {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
</style> {% endcomment %}
|
||||
<!-- {{ form.media }} -->
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<div class="modal-dialog modal-dialog-centered related-finding-modal bg-dark">
|
||||
<div class="modal-content bg-dark text-light border-secondary">
|
||||
<div class="modal-content bg-dark text-light">
|
||||
<h5 class="modal-title">Related findings from case</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<details class="help-text">
|
||||
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||
<p>This form is designed to quickly populate the same finding across different series.</p>
|
||||
<p>Cloning a finding from this form will populate the details with a blank viewport (you will need to add visual annotation and set the display as intended).</p>
|
||||
</details>
|
||||
<ul class="list-group list-group-flush bg-dark">
|
||||
{% for finding in findings %}
|
||||
<li class="list-group-item bg-dark text-light border-secondary">
|
||||
<div class="fw-bold mb-1 text-info">{{ finding.title }}</div>
|
||||
{% if finding.description %}
|
||||
Description:
|
||||
<div class="text-muted small mb-1" style="color:#b0b0b0 !important;">{{ finding.description|truncatechars:100 }}</div>
|
||||
{% endif %}
|
||||
{% if finding.findings.all %}
|
||||
<div class="mb-1">
|
||||
<span class="fw-semibold text-warning">Findings:</span>
|
||||
{% for subfinding in finding.findings.all %}
|
||||
{{ subfinding.get_link }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if finding.structures.all %}
|
||||
<div class="mb-1">
|
||||
<span class="fw-semibold text-warning">Structures:</span>
|
||||
{% for structure in finding.structures.all %}
|
||||
{{ structure.get_link }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if finding.conditions.all %}
|
||||
<div class="mb-1">
|
||||
<span class="fw-semibold text-warning">Conditions:</span>
|
||||
{% for condition in finding.conditions.all %}
|
||||
{{ condition.get_link }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mt-2">
|
||||
<button
|
||||
class="btn btn-sm btn-primary text-light use-finding-base-btn"
|
||||
type="button"
|
||||
data-finding-id="{{ finding.pk }}"
|
||||
data-finding-description="{{ finding.description|default_if_none:''|escapejs }}"
|
||||
{% if finding.conditions.all %}
|
||||
data-finding-conditions="{% for c in finding.conditions.all %}{{ c.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
|
||||
data-finding-conditions-names="{% for c in finding.conditions.all %}{{ c }}{% if not forloop.last %}|{% endif %}{% endfor %}"
|
||||
{% endif %}
|
||||
{% if finding.structures.all %}
|
||||
data-finding-structures="{% for s in finding.structures.all %}{{ s.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
|
||||
data-finding-structures-names="{% for s in finding.structures.all %}{{ s }}{% if not forloop.last %}|{% endif %}{% endfor %}"
|
||||
{% endif %}
|
||||
{% if finding.findings.all %}
|
||||
data-finding-findings="{% for f in finding.findings.all %}{{ f.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
|
||||
data-finding-findings-names="{% for f in finding.findings.all %}{{ f }}{% if not forloop.last %}|{% endif %}{% endfor %}"
|
||||
{% endif %}
|
||||
>Use as finding base</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.use-finding-base-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
console.log('Button clicked:', btn);
|
||||
// Example: populate #hidden-form fields with finding data
|
||||
const form = window.parent.document.querySelector('#hidden-form');
|
||||
if (!form) return;
|
||||
|
||||
form.style.display = 'block'; // Show the form if hidden
|
||||
document.querySelector('#add-finding-button').style.display = 'none'; // Hide the button
|
||||
|
||||
|
||||
// Set values (adjust field names as needed)
|
||||
form.querySelector('[name="description"]').value = btn.getAttribute('data-finding-description');
|
||||
|
||||
|
||||
|
||||
function setSelect2Values(fieldName, dataAttr, namesAttr) {
|
||||
const values = btn.getAttribute(dataAttr);
|
||||
const names = btn.getAttribute(namesAttr);
|
||||
const select = form.querySelector(`[name="${fieldName}"]`);
|
||||
if (values && select) {
|
||||
const valueArr = values.split(',').map(v => v.trim()).filter(Boolean);
|
||||
const nameArr = names ? names.split('|') : [];
|
||||
valueArr.forEach((val, idx) => {
|
||||
if (val && !Array.from(select.options).some(opt => opt.value == val)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = val;
|
||||
opt.text = nameArr[idx] || val;
|
||||
opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
});
|
||||
$(select).val(valueArr).trigger('change');
|
||||
}
|
||||
}
|
||||
|
||||
// Usage inside your click handler:
|
||||
setSelect2Values('conditions', 'data-finding-conditions', 'data-finding-conditions-names');
|
||||
setSelect2Values('structures', 'data-finding-structures', 'data-finding-structures-names');
|
||||
setSelect2Values('findings', 'data-finding-findings', 'data-finding-findings-names');
|
||||
|
||||
|
||||
// Optionally, close the modal (if using Bootstrap modal JS)
|
||||
const modal = window.parent.document.querySelector('#clone-findings-modal');
|
||||
if (modal) {
|
||||
const modalInstance = bootstrap.Modal.getInstance(modal);
|
||||
if (modalInstance) modalInstance.hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -47,6 +47,13 @@
|
||||
{% if editing_finding < 1 %}
|
||||
<button id="add-finding-button">Add finding</button>
|
||||
{% endif %}
|
||||
<button id="clone-finding-button" title="Click to copy the details of a finding that is already associated with the series/case"
|
||||
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
|
||||
hx-target="#clone-findings-modal"
|
||||
hx-trigger="click"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#clone-findings-modal"
|
||||
>Clone existing finding</button>
|
||||
<button id="reset-viewport-button">Reset viewport</button>
|
||||
<div id="finding-form">
|
||||
<div class="hide" id="hidden-form">
|
||||
@@ -182,6 +189,16 @@
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div id="clone-findings-modal"
|
||||
class="modal modal-blur fade"
|
||||
style="display: none"
|
||||
aria-hidden="false"
|
||||
tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
||||
<div class="modal-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
|
||||
@@ -324,6 +324,7 @@ urlpatterns = [
|
||||
# TODO: case context series viewing (so that we can view series in the context of a case)
|
||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||
path("series/", views.SeriesView.as_view(), name="series_view"),
|
||||
path("series_image/<int:pk>/dicom", views.series_image_dicom, name="series_image_dicom"),
|
||||
path(
|
||||
|
||||
+9
-3
@@ -1148,7 +1148,7 @@ def add_collection_to_case_form(request, case_id):
|
||||
|
||||
|
||||
class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
|
||||
"""Clones a existing atlas"""
|
||||
"""Clones a existing atlas case"""
|
||||
|
||||
# fields = '__all__'
|
||||
# #fields = [ 'condition' ]
|
||||
@@ -1169,7 +1169,7 @@ class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
|
||||
else:
|
||||
pass
|
||||
|
||||
initial_data = model_to_dict(old_object, exclude=["id", "previous_case"])
|
||||
initial_data = model_to_dict(old_object, exclude=["id", "previous_case", "history", "discussion", "report"])
|
||||
return initial_data
|
||||
|
||||
|
||||
@@ -3139,4 +3139,10 @@ def remove_series_from_case(request, series_pk, case_pk):
|
||||
@login_required
|
||||
def series_finding_details(request, finding_id):
|
||||
finding = get_object_or_404(SeriesFinding, pk=finding_id)
|
||||
return render(request, 'atlas/series_finding_details.html', {'finding': finding})
|
||||
return render(request, 'atlas/series_finding_details.html', {'finding': finding})
|
||||
|
||||
@login_required
|
||||
def series_finding_related(request, series_id):
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
findings = series.get_related_findings()
|
||||
return render(request, 'atlas/series_finding_related.html', {'series': series, 'findings': findings})
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="position-relative">
|
||||
{% include "django/forms/widgets/textarea.html" %}
|
||||
<button type="button" class="btn btn-danger btn-sm clear-input-button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none;">×</button>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="position-relative">
|
||||
{% include "django/forms/widgets/input.html" %}
|
||||
<button type="button" class="btn btn-danger btn-sm clear-input-button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none;">×</button>
|
||||
</div>
|
||||
@@ -307,6 +307,10 @@ ADMINS = [("Ross","ross@xkjq.uk")]
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ('bootstrap5',)
|
||||
CRISPY_TEMPLATE_PACK = 'bootstrap5'
|
||||
CRISPY_CLASS_CONVERTERS = {
|
||||
"textinput": "clearable_input", # Use clearable_input.html for TextInput
|
||||
"textarea": "clearable_input", # Use clearable_input.html for Textarea
|
||||
}
|
||||
|
||||
DEBUG_CONTAINER = False
|
||||
|
||||
|
||||
@@ -1356,3 +1356,35 @@ span#user-id {
|
||||
.remove-button:hover {
|
||||
opacity: 100%;
|
||||
}
|
||||
|
||||
/* Crispy forms clear button */
|
||||
.position-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Style the clear button */
|
||||
.clear-input-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: none; /* Hidden by default */
|
||||
z-index: 1;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.clear-input-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Show the clear button only when the input field or textarea is hovered and contains text */
|
||||
.position-relative:hover .clear-input-button {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.related-finding-modal {
|
||||
max-width: 600px;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
Reference in New Issue
Block a user