Add django-tasks and django-tasks-db to requirements

- Updated requirements.txt to include django-tasks and django-tasks-db for task management.
This commit is contained in:
Ross
2026-05-18 10:38:14 +01:00
parent 410b71f07f
commit 4d1423134a
4 changed files with 637 additions and 259 deletions
+411 -59
View File
@@ -2,50 +2,68 @@
{% partialdef case-series %}
{% for series in case.get_ordered_series %}
<span class="series-block series-block-interactive" id="series-block-{{ series.pk }}" data-series-id="{{series.pk}}">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}" class="series-block-title-link">
{{series.get_block}}
</a>
<br>
<div class="series-block-actions mt-2 small">
<article class="series-block series-block-interactive"
id="series-block-{{ series.pk }}"
data-series-id="{{series.pk}}"
data-series-label="Series {{ forloop.counter }}"
data-series-name="{{ series|escape }}"
data-series-detail-url="{% url 'atlas:series_detail' pk=series.pk %}"
data-series-popup-url="/atlas/series/{{series.pk}}">
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<div class="series-card-shell">
<button type="button"
class="series-card-main"
data-series-action="details"
data-series-id="{{series.pk}}"
aria-label="Open series actions for Series {{ forloop.counter }}">
<div class="series-card-thumb-wrap">
{% with preview_image=series.images.first %}
{% if preview_image and preview_image.image %}
<img src="{{ preview_image.image.url }}"
alt="Series {{ forloop.counter }} thumbnail"
class="series-card-thumb">
{% else %}
<div class="series-card-thumb series-card-thumb-placeholder">
<i class="bi bi-image"></i>
</div>
{% endif %}
{% endwith %}
</div>
<div class="series-card-content">
<div class="series-card-heading">
<span class="series-card-series-number">Series {{ forloop.counter }}</span>
<span class="series-card-image-count">{{ series.images.count }} image{{ series.images.count|pluralize }}</span>
</div>
<div class="series-card-description">{{ series.description|default:"No description" }}</div>
</div>
</button>
<div class="series-card-controls">
<button type="button"
class="btn btn-outline-primary btn-xs btn-sm select-series-btn"
class="btn btn-outline-primary btn-sm select-series-btn"
style="display: none;"
data-series-id="{{series.pk}}">
Select
</button>
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<button type="button"
class="btn btn-outline-info btn-xs btn-sm series-open-primary-btn"
class="btn btn-outline-info btn-sm series-open-primary-btn"
title="Open series in primary viewport"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}">
<i class="bi bi-eye"></i> Open
</button>
<button type="button"
class="btn btn-outline-info btn-xs btn-sm series-open-new-viewport-btn"
class="btn btn-outline-info btn-sm series-open-new-viewport-btn d-none"
title="Add a viewport and open series"
data-series-id="{{series.pk}}"
data-series-name="{{ series|escape }}">
data-series-name="{{ series|escape }}"
tabindex="-1"
aria-hidden="true">
<i class="bi bi-layout-split"></i> Open + viewport
</button>
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
class="btn btn-outline-secondary btn-xs btn-sm"
title="Open series page">
<i class="bi bi-box-arrow-up-right"></i> Page
</a>
<span class="series-block-popup-link ms-1">
<a href="#"
class="btn btn-outline-secondary btn-xs btn-sm"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">
<i class="bi bi-window"></i> Popup
</a>
</span>
</div>
</span>
</span>
</div>
</article>
{% endfor %}
{% endpartialdef %}
@@ -484,27 +502,26 @@
data-named-stacks='{{case.get_case_named_stacks}}'
></div>
</details>
<div class="pre-whitespace multi-image-block"><b>Series:</b>
<form>
<div class="d-flex flex-wrap gap-2 align-items-start" id="case-series-row">
{% partial case-series %}
</div>
</form>
<div class="d-flex flex-column gap-2 align-items-start">
<details class="series-panel multi-image-block" id="case-series-panel" open>
<summary class="series-panel-summary">
<div>
<span class="series-panel-title">Series</span>
<span class="series-panel-subtitle">Compact cards by default. Open a card for more actions.</span>
</div>
<span class="series-panel-badge">{{ case.get_ordered_series|length }} total</span>
</summary>
<div class="series-panel-body">
<div class="series-toolbar">
<button type="button"
class="btn btn-outline-primary btn-sm"
class="btn btn-outline-primary btn-sm series-toolbar-btn"
onclick="window.location.href='{% url 'atlas:user_uploads_case' case_id=case.pk %}'">
Import new uploads
</button>
</div>
<details class="series-actions" id="series-actions">
<summary>
<button type="button" class="btn btn-outline-secondary btn-sm" title="Click to manage series" onclick="this.closest('details').open = !this.closest('details').open;">
Manage Series
</button>
</summary>
<div class="series-actions-body">
<details class="series-actions" id="series-actions">
<summary class="series-actions-summary">
<span class="btn btn-outline-secondary btn-sm series-toolbar-btn">Manage series</span>
</summary>
<div class="series-actions-body">
<a class="btn btn-sm btn-outline-secondary text-start" href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a>
<div class="series-selection-toolbar d-flex flex-wrap gap-2">
@@ -550,6 +567,16 @@
>
Remove selected series from case
</button>
<button hx-post="{% url 'atlas:series_bulk_delete' %}"
title="delete selected series"
hx-include="[name='series-ids']:checked"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Delete the selected series from the database? This cannot be undone."
class="btn btn-sm btn-danger text-start"
type="button">
Delete selected series
</button>
<button type="button"
class="btn btn-sm btn-secondary text-start"
data-bs-toggle="modal"
@@ -633,13 +660,19 @@
<div class="text-muted small mt-2">Case move options are unavailable in this view.</div>
{% endif %}
</details>
</div>
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
<span id="series-action-results" ></span>
</div>
</details>
</div>
<form>
<div class="series-card-grid" id="case-series-row">
{% partial case-series %}
</div>
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
<span id="series-action-results" ></span>
</div>
</details>
</form>
</div>
</div>
</details>
<div class="container">
@@ -955,6 +988,37 @@
</div>
</div>
<div class="modal fade" id="seriesActionsModal" tabindex="-1" aria-labelledby="seriesActionsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title" id="seriesActionsModalLabel">Series actions</h5>
<div class="small text-muted" id="seriesActionsModalSubheading"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-flex flex-column gap-2">
<button type="button" class="btn btn-outline-info" id="series-modal-open-primary-btn">
<i class="bi bi-eye"></i> Open in viewer
</button>
<button type="button" class="btn btn-outline-info" id="series-modal-open-new-viewport-btn">
<i class="bi bi-layout-split"></i> Open with new viewport
</button>
<a href="#" class="btn btn-outline-secondary" id="series-modal-page-link">
<i class="bi bi-box-arrow-up-right"></i> Open series page
</a>
<button type="button" class="btn btn-outline-secondary" id="series-modal-popup-btn">
<i class="bi bi-window"></i> Open popup window
</button>
<button type="button" class="btn btn-outline-primary" id="series-modal-select-btn" style="display:none;">
Select for manage series
</button>
</div>
</div>
</div>
</div>
<script>
// From series_viewer.html
function loadAnnotationAndViewportOnElement(annotation, viewport, dicom_element, current_image_id_index) {
@@ -1079,12 +1143,77 @@
const seriesCheckboxes = document.querySelectorAll("input[type='checkbox'][name='series-ids']");
const selectAllBtn = document.getElementById("series-select-all-btn");
const deselectAllBtn = document.getElementById("series-deselect-all-btn");
const seriesActionsModalEl = document.getElementById("seriesActionsModal");
const seriesActionsModal = seriesActionsModalEl ? new bootstrap.Modal(seriesActionsModalEl) : null;
const seriesModalSubheading = document.getElementById("seriesActionsModalSubheading");
const seriesModalOpenPrimaryBtn = document.getElementById("series-modal-open-primary-btn");
const seriesModalOpenNewViewportBtn = document.getElementById("series-modal-open-new-viewport-btn");
const seriesModalPageLink = document.getElementById("series-modal-page-link");
const seriesModalPopupBtn = document.getElementById("series-modal-popup-btn");
const seriesModalSelectBtn = document.getElementById("series-modal-select-btn");
let activeSeriesCard = null;
function toggleCheckboxes() {
const isOpen = detailsElement && detailsElement.hasAttribute("open");
buttons.forEach((button) => {
button.style.display = isOpen ? "inline-block" : "none";
});
if (seriesModalSelectBtn) {
seriesModalSelectBtn.style.display = isOpen ? "inline-block" : "none";
}
}
function toggleSeriesSelection(card) {
if (!card) {
return;
}
const checkbox = card.querySelector("input[type='checkbox'][name='series-ids']");
if (!checkbox) {
return;
}
checkbox.checked = !checkbox.checked;
syncSeriesSelectionUi();
}
function openSeriesActionsModal(card) {
if (!card || !seriesActionsModal) {
return;
}
activeSeriesCard = card;
const seriesId = card.dataset.seriesId;
const seriesLabel = card.dataset.seriesLabel || "Series";
const seriesName = card.dataset.seriesName || seriesLabel;
const detailUrl = card.dataset.seriesDetailUrl;
const popupUrl = card.dataset.seriesPopupUrl;
if (seriesModalSubheading) {
seriesModalSubheading.textContent = `${seriesLabel} · ${seriesName}`;
}
if (seriesModalPageLink) {
seriesModalPageLink.href = detailUrl || "#";
}
if (seriesModalPopupBtn) {
seriesModalPopupBtn.onclick = function() {
return window.create_popup_window(popupUrl, 'Series');
};
}
if (seriesModalOpenPrimaryBtn) {
seriesModalOpenPrimaryBtn.dataset.seriesId = seriesId;
seriesModalOpenPrimaryBtn.dataset.seriesName = seriesName;
}
if (seriesModalOpenNewViewportBtn) {
seriesModalOpenNewViewportBtn.dataset.seriesId = seriesId;
seriesModalOpenNewViewportBtn.dataset.seriesName = seriesName;
}
if (seriesModalSelectBtn) {
const checkbox = card.querySelector("input[type='checkbox'][name='series-ids']");
const isSelected = checkbox ? checkbox.checked : false;
seriesModalSelectBtn.textContent = isSelected ? "Deselect from manage series" : "Select for manage series";
}
seriesActionsModal.show();
}
function syncSeriesSelectionUi() {
@@ -1119,12 +1248,48 @@
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
const parent = btn.closest(".series-block");
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
checkbox.checked = !checkbox.checked;
syncSeriesSelectionUi();
toggleSeriesSelection(parent);
});
});
document.querySelectorAll("[data-series-action='details']").forEach(function(btn) {
btn.addEventListener("click", function() {
openSeriesActionsModal(btn.closest(".series-block"));
});
});
if (seriesModalSelectBtn) {
seriesModalSelectBtn.addEventListener("click", function() {
toggleSeriesSelection(activeSeriesCard);
const checkbox = activeSeriesCard ? activeSeriesCard.querySelector("input[type='checkbox'][name='series-ids']") : null;
seriesModalSelectBtn.textContent = checkbox && checkbox.checked ? "Deselect from manage series" : "Select for manage series";
});
}
if (seriesModalOpenPrimaryBtn) {
seriesModalOpenPrimaryBtn.addEventListener("click", function() {
const matchingButton = document.querySelector(`.series-open-primary-btn[data-series-id='${this.dataset.seriesId}']`);
if (matchingButton) {
matchingButton.click();
if (seriesActionsModal) {
seriesActionsModal.hide();
}
}
});
}
if (seriesModalOpenNewViewportBtn) {
seriesModalOpenNewViewportBtn.addEventListener("click", function() {
const matchingButton = document.querySelector(`.series-open-new-viewport-btn[data-series-id='${this.dataset.seriesId}']`);
if (matchingButton) {
matchingButton.click();
if (seriesActionsModal) {
seriesActionsModal.hide();
}
}
});
}
seriesCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", syncSeriesSelectionUi);
});
@@ -1566,24 +1731,187 @@
#series-reorder-list .drag-over {
border-top: 2px solid #0d6efd;
}
.series-panel {
border: 1px solid rgba(13,110,253,0.2);
border-radius: 1rem;
background: linear-gradient(180deg, rgba(13,110,253,0.05), rgba(255,255,255,0.02));
padding: .25rem;
margin-top: 1rem;
overflow: hidden;
}
.series-panel-summary {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
cursor: pointer;
}
.series-panel-summary::-webkit-details-marker { display: none; }
.series-panel-title {
display: block;
font-size: 1rem;
font-weight: 700;
letter-spacing: .01em;
}
.series-panel-subtitle {
display: block;
color: var(--bs-secondary-color, #6c757d);
font-size: .85rem;
margin-top: .15rem;
}
.series-panel-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 5.5rem;
padding: .35rem .75rem;
border-radius: 999px;
background: rgba(13,110,253,0.12);
color: #9ec5fe;
font-size: .8rem;
font-weight: 600;
}
.series-panel-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1.1rem 1.1rem;
}
.series-toolbar {
display: grid;
grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
gap: .75rem;
align-items: start;
}
.series-toolbar-btn {
width: 100%;
justify-content: center;
}
.series-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: .85rem;
align-items: stretch;
}
.series-block {
min-width: 0;
}
.series-card-shell {
display: flex;
flex-direction: column;
gap: .65rem;
height: 100%;
padding: .85rem;
border: 1px solid rgba(255,255,255,0.08);
border-radius: .9rem;
background: rgba(15,23,42,0.22);
box-shadow: 0 10px 24px rgba(0,0,0,0.08);
}
.series-card-main {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: .85rem;
width: 100%;
border: 0;
background: transparent;
padding: 0;
text-align: left;
color: inherit;
}
.series-card-main:hover .series-card-description,
.series-card-main:focus-visible .series-card-description {
color: var(--bs-emphasis-color, #fff);
}
.series-card-main:focus-visible {
outline: 2px solid rgba(13,110,253,0.55);
outline-offset: 4px;
border-radius: .6rem;
}
.series-card-thumb-wrap {
width: 84px;
}
.series-card-thumb {
width: 84px;
height: 84px;
object-fit: cover;
border-radius: .75rem;
border: 1px solid rgba(255,255,255,0.12);
display: block;
background: rgba(255,255,255,0.04);
}
.series-card-thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--bs-secondary-color, #6c757d);
font-size: 1.35rem;
}
.series-card-content {
min-width: 0;
display: flex;
flex-direction: column;
gap: .35rem;
}
.series-card-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .45rem .6rem;
}
.series-card-series-number {
font-weight: 700;
}
.series-card-image-count {
font-size: .8rem;
color: var(--bs-secondary-color, #6c757d);
}
.series-card-description {
font-size: .95rem;
line-height: 1.35;
color: rgba(255,255,255,0.88);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 2.55em;
}
.series-card-controls {
display: flex;
flex-wrap: wrap;
gap: .5rem;
align-items: center;
justify-content: space-between;
}
.series-card-controls .btn {
flex: 1 1 auto;
}
.series-block.highlight-series .series-card-shell,
.series-block:hover .series-card-shell {
border-color: rgba(13,110,253,0.45);
box-shadow: 0 14px 28px rgba(13,110,253,0.12);
transform: translateY(-1px);
}
.series-actions[open] {
border: 2px solid #0d6efd;
border-radius: 6px;
padding: 8px;
margin-top: 8px;
border-radius: .85rem;
padding: .85rem;
}
.series-actions {
flex: 1 1 auto;
min-width: 0; /* allow flex children to shrink without overflow */
min-width: 0;
display: flex;
flex-direction: column;
gap: .5rem;
border: 1px solid rgba(0,0,0,0.06);
border-radius: .375rem;
border-radius: .85rem;
padding: .5rem;
background: rgba(255,255,255,0.02);
margin-top: .5rem;
}
.series-actions-summary { list-style: none; }
.series-actions-summary::-webkit-details-marker { display:none; }
.series-actions-body {
display: flex;
flex-direction: column;
@@ -1605,12 +1933,36 @@
.series-move-panel .case-details {
margin-bottom: .5rem;
}
.series-actions summary { list-style: none; }
.series-actions summary::-webkit-details-marker { display:none; }
.series-actions .btn {
vertical-align: middle;
text-align: left;
}
@media (max-width: 991.98px) {
.series-toolbar {
grid-template-columns: 1fr;
}
}
@media (max-width: 575.98px) {
.series-panel-summary {
flex-direction: column;
align-items: flex-start;
}
.series-panel-badge {
min-width: 0;
}
.series-card-main {
grid-template-columns: 72px minmax(0, 1fr);
}
.series-card-thumb-wrap,
.series-card-thumb {
width: 72px;
height: 72px;
}
.series-card-controls {
flex-direction: column;
align-items: stretch;
}
}
@media (prefers-color-scheme: dark) {
.series-actions { background: rgba(255,255,255,0.02); border-color: rgba(255,255,255,0.04); }
}
+40 -16
View File
@@ -9014,21 +9014,28 @@ def remove_case_from_collection(request, case_pk, collection_pk):
return HttpResponse(f"Case removed from collection: {collection.name}")
@login_required
@require_POST
def remove_selected_series_from_case(request, case_pk):
ids = request.POST.getlist('series-ids')
case = Case.objects.get(pk=case_pk)
ids = request.POST.getlist('series-ids') or request.POST.getlist('series_ids')
case = get_object_or_404(Case, pk=case_pk)
if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit this case.", status=403)
if not ids:
return HttpResponse("No series selected for removal.")
return HttpResponse("No series selected for removal.", status=400)
linked_series_ids = list(
Series.objects.filter(pk__in=ids, case=case).values_list("pk", flat=True).distinct()
)
if not linked_series_ids:
return HttpResponse("No valid series selected for removal.", status=400)
# Remove the association (for a through model)
SeriesDetail.objects.filter(case=case, series_id__in=ids).delete()
return HttpResponse(f"Series removed from case.")
SeriesDetail.objects.filter(case=case, series_id__in=linked_series_ids).delete()
return HttpResponse(f"Removed {len(linked_series_ids)} series from this case.")
@login_required
@@ -9225,18 +9232,35 @@ def seriesfinding_migration_status(request):
"findings_status": findings_status
})
@login_required
@require_POST
def series_bulk_delete(request):
if request.method == "POST":
ids = request.POST.getlist("series_ids")
# Only delete series the user has permission to delete
series_to_delete = Series.objects.filter(pk__in=ids)
deleted_ids = []
for series in series_to_delete:
if series.check_user_can_edit(request.user):
series.delete()
deleted_ids.append(series.pk)
# Optionally, you could add a message here about which were deleted
return redirect('atlas:series_view')
ids = request.POST.getlist("series_ids") or request.POST.getlist("series-ids")
if not ids:
response = HttpResponse("No series selected to delete.", status=400)
return response if request.htmx else redirect("atlas:series_view")
series_to_delete = list(Series.objects.filter(pk__in=ids).distinct())
if not series_to_delete:
response = HttpResponse("No valid series selected to delete.", status=400)
return response if request.htmx else redirect("atlas:series_view")
forbidden_series = [series.pk for series in series_to_delete if not series.check_user_can_edit(request.user)]
if forbidden_series:
response = HttpResponse(
f"You do not have permission to delete {len(forbidden_series)} selected series.",
status=403,
)
return response if request.htmx else redirect("atlas:series_view")
deleted_count = len(series_to_delete)
for series in series_to_delete:
series.delete()
response = HttpResponse(f"Deleted {deleted_count} series.")
if request.htmx:
return response
return redirect(request.META.get("HTTP_REFERER") or reverse("atlas:series_view"))
@user_is_collection_author_or_atlas_editor
def collection_case_displaysetup(request, collection_id, case_number=None, case_id=None):
+183 -183
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -61,4 +61,6 @@ pygments
celery[redis]
redis
pywatchman
numpy==2.4.0
numpy==2.4.0
django-tasks
django-tasks-db