Imrpvoe the case links

This commit is contained in:
Ross
2026-06-12 22:55:50 +01:00
parent 96d5fa4a2e
commit aaee3577ad
4 changed files with 260 additions and 13 deletions
+42 -13
View File
@@ -426,21 +426,40 @@
</div>
</h2>
{% if case.previous_case or case.next_case %}
<div class="alert alert-info" role="alert">
This case is part of a <a href='{% url "atlas:linked_cases_overview" case.pk %}'>series.</a><br/>
<div class="row">
{% if case.previous_case %}
<div class="col-sm-6 mb-3 mb-sm-0">
<h5 class="card-title">Previous Case</h5>
{{ case.previous_case.get_link }}
<div class="card border-info bg-info-subtle mb-3">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center border-bottom border-info-subtle pb-2 mb-2">
<span class="fw-semibold text-info-emphasis"><i class="bi bi-layers-half me-1"></i> Part of a Case Series</span>
<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>
</div>
<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>
{% endif %}
{% if case.next_case %}
<div class="col-sm-6">
<h5 class="card-title">Next Case</h5>
{{ case.next_case.get_link }}
<div class="col-6 d-flex flex-column justify-content-center text-sm-end align-items-sm-end">
{% if case.next_case %}
<div>
<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' }}">
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>
{% endif %}
</div>
</div>
</div>
{% endif %}
@@ -1123,6 +1142,16 @@
}
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");
let dicomViewerLoaded = false;
@@ -28,6 +28,12 @@
{% 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>
{% 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>
@@ -104,6 +110,39 @@
</div>
{% 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 %}
<div class="card border-info-subtle shadow-sm mb-4">
<div class="card-body">
@@ -274,3 +313,91 @@
</div>
</div>
{% 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 %}
+55
View File
@@ -131,3 +131,58 @@ def test_case_prior_and_future_relation_post(client, make_case):
)
assert response_remove.status_code == 200
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
+36
View File
@@ -9873,6 +9873,7 @@ def linked_cases_overview(request, case_id):
case_editor_prefix = "case_series_editor"
show_study_dates_editor = request.GET.get("show_dates") == "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_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.")
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:
if not case.check_user_can_edit(request.user):
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,
"show_study_dates_editor": show_study_dates_editor,
"show_case_editor": show_case_editor,
"show_reorder_editor": show_reorder_editor,
"case_editor_formset": case_editor_formset,
"case_editor_rows": case_editor_rows,
"case_editor_apply_field_choices": case_editor_apply_field_choices,