Imrpvoe the case links
This commit is contained in:
@@ -426,21 +426,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
{% if case.previous_case or case.next_case %}
|
{% if case.previous_case or case.next_case %}
|
||||||
<div class="alert alert-info" role="alert">
|
<div class="card border-info bg-info-subtle mb-3">
|
||||||
This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/>
|
<div class="card-body p-3">
|
||||||
<div class="row">
|
<div class="d-flex justify-content-between align-items-center border-bottom border-info-subtle pb-2 mb-2">
|
||||||
{% if case.previous_case %}
|
<span class="fw-semibold text-info-emphasis"><i class="bi bi-layers-half me-1"></i> Part of a Case Series</span>
|
||||||
<div class="col-sm-6 mb-3 mb-sm-0">
|
<a href='{% url "atlas:linked_cases_overview" case.pk %}' class="btn btn-sm btn-outline-info text-decoration-none py-0 px-2" style="font-size: 0.8rem;">View Series Overview</a>
|
||||||
<h5 class="card-title">Previous Case</h5>
|
</div>
|
||||||
{{ case.previous_case.get_link }}
|
<div class="row text-center text-sm-start g-2">
|
||||||
|
<div class="col-6 border-end border-info-subtle d-flex flex-column justify-content-center">
|
||||||
|
{% if case.previous_case %}
|
||||||
|
<div>
|
||||||
|
<a href="{{ case.previous_case.get_absolute_url }}" class="text-decoration-none fw-semibold link-info" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-delay='{"show":500,"hide":100}' title="{{ case.previous_case.description|striptags|default:'No description' }}">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> Previous Case: {{ case.previous_case.title|default:'Untitled Case' }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="small text-muted italic">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i> No previous case
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div class="col-6 d-flex flex-column justify-content-center text-sm-end align-items-sm-end">
|
||||||
{% if case.next_case %}
|
{% if case.next_case %}
|
||||||
<div class="col-sm-6">
|
<div>
|
||||||
<h5 class="card-title">Next Case</h5>
|
<a href="{{ case.next_case.get_absolute_url }}" class="text-decoration-none fw-semibold link-info" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-delay='{"show":500,"hide":100}' title="{{ case.next_case.description|striptags|default:'No description' }}">
|
||||||
{{ case.next_case.get_link }}
|
Next Case: {{ case.next_case.title|default:'Untitled Case' }} <i class="bi bi-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="small text-muted italic">
|
||||||
|
No follow-up case <i class="bi bi-arrow-right ms-1"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1123,6 +1142,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Initialize tooltips
|
||||||
|
function initAlertTooltips() {
|
||||||
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function(el) {
|
||||||
|
try { new bootstrap.Tooltip(el); } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
initAlertTooltips();
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function() {
|
||||||
|
initAlertTooltips();
|
||||||
|
});
|
||||||
|
|
||||||
const dicomDetails = document.getElementById("dicom-viewer-details");
|
const dicomDetails = document.getElementById("dicom-viewer-details");
|
||||||
let dicomViewerLoaded = false;
|
let dicomViewerLoaded = false;
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
{% if show_study_dates_editor %}
|
{% if show_study_dates_editor %}
|
||||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide study dates editor</a>
|
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide study dates editor</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if can_edit_chain and not show_reorder_editor %}
|
||||||
|
<a class="btn btn-outline-warning" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_reorder=1">Open case reorder editor</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if show_reorder_editor %}
|
||||||
|
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide case reorder editor</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,6 +110,39 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_edit_chain and show_reorder_editor %}
|
||||||
|
<div class="card border-warning-subtle shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h5 mb-1 text-warning-emphasis">Reorder Cases</h2>
|
||||||
|
<p class="text-muted mb-3">Drag and drop cases to reorder them, or use the Up/Down buttons, then click Save.</p>
|
||||||
|
|
||||||
|
<form method="post" id="reorder-cases-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="form_action" value="reorder_cases">
|
||||||
|
<ul id="cases-reorder-list" class="list-group mb-3">
|
||||||
|
{% for linked_case in chain_cases %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center {% if linked_case.pk == current_case.pk %}border-primary{% endif %}" data-case-id="{{ linked_case.pk }}">
|
||||||
|
<span>
|
||||||
|
<i class="bi bi-grip-vertical me-2 text-muted drag-handle" style="cursor: grab;"></i>
|
||||||
|
<span class="fw-semibold">Case {{ linked_case.pk }}:</span> {{ linked_case.title|default:"Untitled case" }}
|
||||||
|
{% if linked_case.pk == current_case.pk %}<span class="badge bg-primary ms-1">Current</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary move-up">↑</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary move-down">↓</button>
|
||||||
|
</span>
|
||||||
|
<input type="hidden" name="case_order" value="{{ linked_case.pk }}">
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-warning">Save Case Order</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if show_case_editor and case_editor_formset %}
|
{% if show_case_editor and case_editor_formset %}
|
||||||
<div class="card border-info-subtle shadow-sm mb-4">
|
<div class="card border-info-subtle shadow-sm mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -274,3 +313,91 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block css %}
|
||||||
|
<style>
|
||||||
|
#cases-reorder-list .dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
box-shadow: 0 4px 16px rgba(255, 193, 7, 0.35);
|
||||||
|
}
|
||||||
|
#cases-reorder-list .drag-over {
|
||||||
|
border: 2px dashed #ffc107 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
// Move up/down logic
|
||||||
|
document.querySelectorAll("#cases-reorder-list .move-up, #cases-reorder-list .move-down").forEach(function(btn) {
|
||||||
|
btn.addEventListener("click", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const li = btn.closest("li");
|
||||||
|
if (btn.classList.contains("move-up") && li.previousElementSibling) {
|
||||||
|
li.parentNode.insertBefore(li, li.previousElementSibling);
|
||||||
|
}
|
||||||
|
if (btn.classList.contains("move-down") && li.nextElementSibling) {
|
||||||
|
li.parentNode.insertBefore(li.nextElementSibling, li);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// On submit, update the hidden inputs to match the new order
|
||||||
|
const reorderCasesForm = document.getElementById("reorder-cases-form");
|
||||||
|
if (reorderCasesForm) {
|
||||||
|
reorderCasesForm.addEventListener("submit", function(e) {
|
||||||
|
const lis = document.querySelectorAll("#cases-reorder-list li");
|
||||||
|
lis.forEach((li, idx) => {
|
||||||
|
const input = li.querySelector("input[name='case_order']");
|
||||||
|
if (input) {
|
||||||
|
input.setAttribute("name", "case_order_" + idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag and drop reordering for cases list
|
||||||
|
const reorderList = document.getElementById("cases-reorder-list");
|
||||||
|
let draggedItem = null;
|
||||||
|
|
||||||
|
if (reorderList) {
|
||||||
|
reorderList.querySelectorAll("li").forEach(function(li) {
|
||||||
|
li.setAttribute("draggable", "true");
|
||||||
|
|
||||||
|
li.addEventListener("dragstart", function(e) {
|
||||||
|
draggedItem = li;
|
||||||
|
li.classList.add("dragging");
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
|
||||||
|
li.addEventListener("dragend", function() {
|
||||||
|
draggedItem = null;
|
||||||
|
li.classList.remove("dragging");
|
||||||
|
reorderList.querySelectorAll("li").forEach(el => el.classList.remove("drag-over"));
|
||||||
|
});
|
||||||
|
|
||||||
|
li.addEventListener("dragover", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
li.classList.add("drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
li.addEventListener("dragleave", function() {
|
||||||
|
li.classList.remove("drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
li.addEventListener("drop", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
li.classList.remove("drag-over");
|
||||||
|
if (draggedItem && draggedItem !== li) {
|
||||||
|
const rect = li.getBoundingClientRect();
|
||||||
|
const next = (e.clientY - rect.top) / (rect.bottom - rect.top) > 0.5;
|
||||||
|
li.parentNode.insertBefore(draggedItem, next ? li.nextElementSibling : li);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -131,3 +131,58 @@ def test_case_prior_and_future_relation_post(client, make_case):
|
|||||||
)
|
)
|
||||||
assert response_remove.status_code == 200
|
assert response_remove.status_code == 200
|
||||||
assert not CasePrior.objects.filter(casedetail=casedetail, prior_case=case3).exists()
|
assert not CasePrior.objects.filter(casedetail=casedetail, prior_case=case3).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_linked_cases_reordering(client, make_case):
|
||||||
|
# Setup user
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="author_user_reorder", password="password", email="author_reorder@test.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
case1 = make_case(title="Case A")
|
||||||
|
case2 = make_case(title="Case B")
|
||||||
|
case3 = make_case(title="Case C")
|
||||||
|
|
||||||
|
# Author permissions
|
||||||
|
case1.author.add(user)
|
||||||
|
case2.author.add(user)
|
||||||
|
case3.author.add(user)
|
||||||
|
|
||||||
|
# Initial order: case1 -> case2 -> case3
|
||||||
|
case2.previous_case = case1
|
||||||
|
case2.save(update_fields=["previous_case"])
|
||||||
|
case3.previous_case = case2
|
||||||
|
case3.save(update_fields=["previous_case"])
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
url = reverse("atlas:linked_cases_overview", kwargs={"case_id": case2.pk})
|
||||||
|
|
||||||
|
# Verify GET works and shows reorder editor when requested
|
||||||
|
response_get = client.get(url + "?show_reorder=1")
|
||||||
|
assert response_get.status_code == 200
|
||||||
|
assert "Reorder Cases" in response_get.content.decode("utf-8")
|
||||||
|
|
||||||
|
# Post new order: case3 -> case1 -> case2
|
||||||
|
response_post = client.post(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
"form_action": "reorder_cases",
|
||||||
|
"case_order_0": case3.pk,
|
||||||
|
"case_order_1": case1.pk,
|
||||||
|
"case_order_2": case2.pk,
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
assert response_post.status_code == 200
|
||||||
|
|
||||||
|
# Refresh cases from database
|
||||||
|
case1.refresh_from_db()
|
||||||
|
case2.refresh_from_db()
|
||||||
|
case3.refresh_from_db()
|
||||||
|
|
||||||
|
# Verify new order in DB: case3 -> case1 -> case2
|
||||||
|
assert case3.previous_case is None
|
||||||
|
assert case1.previous_case == case3
|
||||||
|
assert case2.previous_case == case1
|
||||||
|
|
||||||
|
|||||||
@@ -9873,6 +9873,7 @@ def linked_cases_overview(request, case_id):
|
|||||||
case_editor_prefix = "case_series_editor"
|
case_editor_prefix = "case_series_editor"
|
||||||
show_study_dates_editor = request.GET.get("show_dates") == "1"
|
show_study_dates_editor = request.GET.get("show_dates") == "1"
|
||||||
show_case_editor = request.GET.get("show_editor") == "1"
|
show_case_editor = request.GET.get("show_editor") == "1"
|
||||||
|
show_reorder_editor = request.GET.get("show_reorder") == "1"
|
||||||
case_editor_selected_field = ""
|
case_editor_selected_field = ""
|
||||||
case_editor_selected_source = ""
|
case_editor_selected_source = ""
|
||||||
|
|
||||||
@@ -9976,6 +9977,40 @@ def linked_cases_overview(request, case_id):
|
|||||||
|
|
||||||
messages.error(request, "Please correct the errors in the linked case editor form.")
|
messages.error(request, "Please correct the errors in the linked case editor form.")
|
||||||
|
|
||||||
|
elif form_action == "reorder_cases":
|
||||||
|
if not case.check_user_can_edit(request.user):
|
||||||
|
raise PermissionDenied("You do not have permission to edit this case chain.")
|
||||||
|
|
||||||
|
# Get new order from POST
|
||||||
|
order = []
|
||||||
|
for key in sorted([k for k in request.POST if k.startswith("case_order_")]):
|
||||||
|
try:
|
||||||
|
order.append(int(request.POST[key]))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if set(order) == set(chain_case_ids):
|
||||||
|
from django.db import transaction
|
||||||
|
with transaction.atomic():
|
||||||
|
cases = list(Case.objects.filter(pk__in=chain_case_ids))
|
||||||
|
for c in cases:
|
||||||
|
c.previous_case = None
|
||||||
|
c.save(update_fields=["previous_case"])
|
||||||
|
|
||||||
|
cases_by_id = {c.pk: c for c in cases}
|
||||||
|
for i, pk in enumerate(order):
|
||||||
|
if i > 0:
|
||||||
|
current_c = cases_by_id[pk]
|
||||||
|
prev_c = cases_by_id[order[i-1]]
|
||||||
|
current_c.previous_case = prev_c
|
||||||
|
current_c.save(update_fields=["previous_case"])
|
||||||
|
|
||||||
|
messages.success(request, "Saved case chain order.")
|
||||||
|
return redirect(f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_reorder=1")
|
||||||
|
else:
|
||||||
|
messages.error(request, "Invalid case ordering submitted.")
|
||||||
|
return redirect(f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_reorder=1")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if not case.check_user_can_edit(request.user):
|
if not case.check_user_can_edit(request.user):
|
||||||
raise PermissionDenied("You do not have permission to edit this case chain.")
|
raise PermissionDenied("You do not have permission to edit this case chain.")
|
||||||
@@ -10061,6 +10096,7 @@ def linked_cases_overview(request, case_id):
|
|||||||
"direct_next": direct_next,
|
"direct_next": direct_next,
|
||||||
"show_study_dates_editor": show_study_dates_editor,
|
"show_study_dates_editor": show_study_dates_editor,
|
||||||
"show_case_editor": show_case_editor,
|
"show_case_editor": show_case_editor,
|
||||||
|
"show_reorder_editor": show_reorder_editor,
|
||||||
"case_editor_formset": case_editor_formset,
|
"case_editor_formset": case_editor_formset,
|
||||||
"case_editor_rows": case_editor_rows,
|
"case_editor_rows": case_editor_rows,
|
||||||
"case_editor_apply_field_choices": case_editor_apply_field_choices,
|
"case_editor_apply_field_choices": case_editor_apply_field_choices,
|
||||||
|
|||||||
Reference in New Issue
Block a user