feat: Enhance user uploads with support for partial imports and improved UI feedback
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
<li class="series-item">
|
<li class="series-item {% if series_item_classes %}{{ series_item_classes }}{% endif %}">
|
||||||
<div class="series-left">
|
<div class="series-left">
|
||||||
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
|
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
|
||||||
<h5 class="series-title">{{ tags.SeriesDescription|default:series }}</h5>
|
<h5 class="series-title">
|
||||||
|
{{ tags.SeriesDescription|default:series }}
|
||||||
|
{% if series_is_partial %}
|
||||||
|
<span class="badge bg-warning text-dark ms-2">Part imported</span>
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
<div class="series-meta small text-muted">
|
<div class="series-meta small text-muted">
|
||||||
<div>UID: <code class="series-uid">{{ series }}</code></div>
|
<div>UID: <code class="series-uid">{{ series }}</code></div>
|
||||||
{% if tags.SeriesNumber %}<div>Series #: <span class="series-number">{{ tags.SeriesNumber }}</span></div>{% endif %}
|
{% if tags.SeriesNumber %}<div>Series #: <span class="series-number">{{ tags.SeriesNumber }}</span></div>{% endif %}
|
||||||
@@ -26,6 +31,14 @@
|
|||||||
>View tags</a>
|
>View tags</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
{% if series_is_partial %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-warning mt-2 import-partial-series-btn"
|
||||||
|
data-series-uids="{{ series }}"
|
||||||
|
{% if import_case_id %}data-case-id="{{ import_case_id }}"{% endif %}
|
||||||
|
>Finish import</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-100 mt-2">
|
<div class="w-100 mt-2">
|
||||||
<div id="series-tags-{{ series }}" class="series-tags-placeholder"></div>
|
<div id="series-tags-{{ series }}" class="series-tags-placeholder"></div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<div id="series-list">
|
<div id="series-list">
|
||||||
{% if grouped_series %}
|
{% if grouped_series %}
|
||||||
{% for study_uid, study in grouped_series %}
|
{% for study_uid, study in grouped_series %}
|
||||||
<details class="study-block" open>
|
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
|
||||||
<summary>
|
<summary>
|
||||||
<strong>{{ study.description|default:"(no description)" }}</strong>
|
<strong>{{ study.description|default:"(no description)" }}</strong>
|
||||||
<small class="text-muted"> ({{ study_uid }}) — {{ study.series|length }} series</small>
|
<small class="text-muted"> ({{ study_uid }}) — {{ study.series|length }} series</small>
|
||||||
@@ -27,6 +27,20 @@
|
|||||||
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" title="Deselect all in study">Deselect all</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" title="Deselect all in study">Deselect all</button>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
{% if study.partial_imported_uids %}
|
||||||
|
<div class="alert alert-warning py-2 mt-2 mb-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||||
|
<div>
|
||||||
|
<strong>Partial import detected:</strong>
|
||||||
|
{{ study.partial_imported_count }} series in this study already exist.
|
||||||
|
The highlighted rows can finish importing the remaining images.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-warning import-partial-series-btn"
|
||||||
|
data-series-uids="{{ study.partial_imported_uids|join:',' }}"
|
||||||
|
>Finish these imports</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if study.import_suggestions %}
|
{% if study.import_suggestions %}
|
||||||
<div class="alert alert-info py-2 mt-2 mb-2">
|
<div class="alert alert-info py-2 mt-2 mb-2">
|
||||||
<strong>Study already imported:</strong>
|
<strong>Study already imported:</strong>
|
||||||
@@ -51,7 +65,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<ul class="study-series-list">
|
<ul class="study-series-list">
|
||||||
{% for series, n, tags, date in study.series %}
|
{% for series, n, tags, date in study.series %}
|
||||||
{% include 'atlas/partials/_series_item.html' with show_tags_link=True %}
|
{% if series in study.partial_imported_uids %}
|
||||||
|
{% if case %}
|
||||||
|
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=case.pk %}
|
||||||
|
{% else %}
|
||||||
|
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if case %}
|
||||||
|
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' import_case_id=case.pk %}
|
||||||
|
{% else %}
|
||||||
|
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
@@ -59,7 +85,11 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<ul>
|
<ul>
|
||||||
{% for series, n, tags, date in series_list %}
|
{% for series, n, tags, date in series_list %}
|
||||||
{% include 'atlas/partials/_series_item.html' %}
|
{% if case %}
|
||||||
|
{% include 'atlas/partials/_series_item.html' with series_is_partial=False series_item_classes='' import_case_id=case.pk %}
|
||||||
|
{% else %}
|
||||||
|
{% include 'atlas/partials/_series_item.html' with series_is_partial=False series_item_classes='' %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -68,51 +98,79 @@
|
|||||||
{% if case %}
|
{% if case %}
|
||||||
<p>Importing into case: <a href='{% url "atlas:case_detail" case.pk %}'>{{case}}</a></p>
|
<p>Importing into case: <a href='{% url "atlas:case_detail" case.pk %}'>{{case}}</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<details><summary>Upload settings</summary>
|
<div class="upload-toolbar sticky-top mb-3">
|
||||||
<form>
|
<div class="upload-toolbar__inner">
|
||||||
<div>
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-2">
|
||||||
<fieldset><legend>Select how series should be ordered when uploaded</legend>
|
<div>
|
||||||
<div class="helptext">
|
<div id="selection-count" class="small text-muted">Selected: <span id="selected-count">0</span></div>
|
||||||
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.<br/>
|
<div class="small text-warning-emphasis">Keep this page open while imports are running.</div>
|
||||||
If you are not sure, leave this as the default.
|
</div>
|
||||||
</div>
|
<details class="upload-settings">
|
||||||
<div>
|
<summary>Upload settings</summary>
|
||||||
<input type="radio" id="order-series-slice-location" value="order-series-slice-location" name="order-series" checked>
|
<form class="mt-2">
|
||||||
<label for="order-series-slice-location">Order series by slice location</label><br/>
|
<fieldset>
|
||||||
</div>
|
<legend>Select how series should be ordered when uploaded</legend>
|
||||||
<div>
|
<div class="helptext">
|
||||||
<input type="radio" id="order-series-instance-number" value="order-series-instance-number" name="order-series" >
|
This will affect how series are displayed when viewing with the built in site viewer. By default when exporting from Insight the order is not maintained so you should choose to order either by slice location or instance number.
|
||||||
<label for="order-series-instance-number">Order series by instance number</label><br/>
|
If you are not sure, leave this as the default.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input type="radio" id="order-series-none" value="order-series-none" name="order-series" >
|
<input type="radio" id="order-series-slice-location" value="order-series-slice-location" name="order-series" checked>
|
||||||
<label for="order-series-none">None</label><br/>
|
<label for="order-series-slice-location">Order series by slice location</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
<div>
|
||||||
|
<input type="radio" id="order-series-instance-number" value="order-series-instance-number" name="order-series">
|
||||||
|
<label for="order-series-instance-number">Order series by instance number</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="order-series-none" value="order-series-none" name="order-series">
|
||||||
|
<label for="order-series-none">None</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<div id="selection-actions" class="d-flex align-items-center gap-2 mb-2">
|
<div id="import-progress" class="alert alert-warning d-none mb-3" role="alert">
|
||||||
<div id="selection-count" class="small text-muted">Selected: <span id="selected-count">0</span></div>
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||||
<button id="delete-uploads-button"
|
<div>
|
||||||
hx-post="{% url 'api-1:clear_dicoms' %}"
|
<strong>Import running.</strong>
|
||||||
hx-include="[name='selection']"
|
<span class="d-block">Do not leave this page until the progress bar completes.</span>
|
||||||
hx-confirm="This will clear selected uploads, Continue?"
|
</div>
|
||||||
title="Deleted selected uploads"
|
<div id="import-progress-text" class="small text-muted"></div>
|
||||||
>Delete Uploads</button>
|
</div>
|
||||||
|
<div class="progress" style="height: 1rem;">
|
||||||
|
<div id="import-progress-bar"
|
||||||
|
class="progress-bar progress-bar-striped progress-bar-animated bg-warning text-dark"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: 0%"
|
||||||
|
aria-valuenow="0"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button id="import-dicoms-sequential-button"
|
<div id="selection-actions" class="d-flex flex-wrap align-items-center gap-2">
|
||||||
type="button"
|
<button id="delete-uploads-button"
|
||||||
class="btn btn-primary"
|
class="btn btn-outline-danger"
|
||||||
>Import {% if case %}into case{% endif %}</button>
|
hx-post="{% url 'api-1:clear_dicoms' %}"
|
||||||
<button id="import-into-case-button" type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#caseSelectModal">Import into case…</button>
|
hx-include="[name='selection']"
|
||||||
|
hx-confirm="This will clear selected uploads, Continue?"
|
||||||
|
title="Delete selected uploads"
|
||||||
|
>Delete Uploads</button>
|
||||||
|
|
||||||
|
<button id="import-dicoms-sequential-button"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>Import {% if case %}into case{% endif %}</button>
|
||||||
|
<button id="import-into-case-button" type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#caseSelectModal">Import into case…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="import-status" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="import-status"></div>
|
|
||||||
|
|
||||||
<div class="indicator"><div class="loader"></div>Loading...</div>
|
<div class="indicator"><div class="loader"></div>Loading...</div>
|
||||||
<div id="import-status"></div>
|
|
||||||
|
|
||||||
<div class="imported">
|
<div class="imported">
|
||||||
|
|
||||||
@@ -165,6 +223,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const importCaseId = {% if case %}{{ case.pk }}{% else %}null{% endif %};
|
||||||
|
|
||||||
// Update the selected count shown beside action buttons
|
// Update the selected count shown beside action buttons
|
||||||
function updateSelectionCount() {
|
function updateSelectionCount() {
|
||||||
const all = Array.from(document.querySelectorAll('input[name="selection"]'));
|
const all = Array.from(document.querySelectorAll('input[name="selection"]'));
|
||||||
@@ -180,6 +240,73 @@
|
|||||||
if (importIntoCaseBtn) importIntoCaseBtn.disabled = selected === 0;
|
if (importIntoCaseBtn) importIntoCaseBtn.disabled = selected === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setImportButtonsDisabled(disabled) {
|
||||||
|
document.querySelectorAll('#selection-actions button, .import-partial-series-btn, .import-missing-series-btn').forEach((button) => {
|
||||||
|
button.disabled = disabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImportProgress(completed, total, message) {
|
||||||
|
const progress = document.getElementById('import-progress');
|
||||||
|
const bar = document.getElementById('import-progress-bar');
|
||||||
|
const text = document.getElementById('import-progress-text');
|
||||||
|
if (!progress || !bar || !text) return;
|
||||||
|
|
||||||
|
if (total <= 0) {
|
||||||
|
progress.classList.add('d-none');
|
||||||
|
bar.style.width = '0%';
|
||||||
|
bar.setAttribute('aria-valuenow', '0');
|
||||||
|
bar.textContent = '0%';
|
||||||
|
text.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = Math.min(100, Math.round((completed / total) * 100));
|
||||||
|
progress.classList.remove('d-none');
|
||||||
|
bar.style.width = `${percentage}%`;
|
||||||
|
bar.setAttribute('aria-valuenow', String(percentage));
|
||||||
|
bar.textContent = `${percentage}%`;
|
||||||
|
text.textContent = message || `${completed} of ${total} series imported`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendImportStatus(html) {
|
||||||
|
const statusDiv = document.getElementById('import-status');
|
||||||
|
if (!statusDiv) return;
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'mb-2';
|
||||||
|
wrapper.innerHTML = html;
|
||||||
|
statusDiv.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImportUrl(caseId) {
|
||||||
|
const targetCaseId = caseId || importCaseId;
|
||||||
|
if (targetCaseId) {
|
||||||
|
const importTemplate = "{% url 'api-1:import_dicoms_case' 0 %}";
|
||||||
|
return importTemplate.replace(/0\/?$/, targetCaseId);
|
||||||
|
}
|
||||||
|
return "{% url 'api-1:import_dicoms' %}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImportResponse(data) {
|
||||||
|
if (!Array.isArray(data) || !data.length) {
|
||||||
|
return '<div class="alert alert-success mb-0">No new series were imported.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = data.map((item) => {
|
||||||
|
const series = Array.isArray(item) ? item[0] : null;
|
||||||
|
const link = Array.isArray(item) ? item[1] : '';
|
||||||
|
const uid = series && series.series_instance_uid ? series.series_instance_uid : '';
|
||||||
|
return `<li>${link || 'Imported series'}${uid ? ` <small class="text-muted">(${uid})</small>` : ''}</li>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="alert alert-success mb-0">
|
||||||
|
<strong>Imported ${data.length} series.</strong>
|
||||||
|
<ul class="mb-0 mt-2">${links}</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep count up-to-date on checkbox changes and attribute changes
|
// Keep count up-to-date on checkbox changes and attribute changes
|
||||||
document.body.addEventListener('change', function (e) {
|
document.body.addEventListener('change', function (e) {
|
||||||
if (e.target && e.target.name === 'selection') updateSelectionCount();
|
if (e.target && e.target.name === 'selection') updateSelectionCount();
|
||||||
@@ -218,6 +345,13 @@
|
|||||||
if (!li || li.classList.contains('imported')) return;
|
if (!li || li.classList.contains('imported')) return;
|
||||||
|
|
||||||
li.classList.add('imported');
|
li.classList.add('imported');
|
||||||
|
li.classList.remove('series-item--partial-import');
|
||||||
|
const partialBadge = li.querySelector('.badge.bg-warning');
|
||||||
|
if (partialBadge && partialBadge.textContent.trim() === 'Part imported') {
|
||||||
|
partialBadge.remove();
|
||||||
|
}
|
||||||
|
const partialButton = li.querySelector('.import-partial-series-btn');
|
||||||
|
if (partialButton) partialButton.remove();
|
||||||
if (!li.querySelector('.badge.bg-success')) {
|
if (!li.querySelector('.badge.bg-success')) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'badge bg-success ms-2';
|
badge.className = 'badge bg-success ms-2';
|
||||||
@@ -240,15 +374,20 @@
|
|||||||
async function importSeriesList(selectionList, importUrl, statusPrefix) {
|
async function importSeriesList(selectionList, importUrl, statusPrefix) {
|
||||||
const statusDiv = document.getElementById('import-status');
|
const statusDiv = document.getElementById('import-status');
|
||||||
if (!statusDiv) return;
|
if (!statusDiv) return;
|
||||||
|
|
||||||
statusDiv.innerHTML = '';
|
statusDiv.innerHTML = '';
|
||||||
|
setImportButtonsDisabled(true);
|
||||||
|
setImportProgress(0, selectionList.length, 'Starting import');
|
||||||
|
|
||||||
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
|
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
|
||||||
const csrfToken = '{{ csrf_token }}';
|
const csrfToken = '{{ csrf_token }}';
|
||||||
|
let importedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
for (let i = 0; i < selectionList.length; i++) {
|
for (let i = 0; i < selectionList.length; i++) {
|
||||||
const seriesId = selectionList[i];
|
const seriesId = selectionList[i];
|
||||||
const statusId = `${statusPrefix}-${seriesId}`;
|
const statusId = `${statusPrefix}-${seriesId}`;
|
||||||
statusDiv.innerHTML += `<div id="${statusId}">Importing series ${seriesId}...</div>`;
|
appendImportStatus(`<div id="${statusId}" class="small text-muted">Importing series ${seriesId}...</div>`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(importUrl, {
|
const response = await fetch(importUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -259,21 +398,34 @@
|
|||||||
body: `selection=${encodeURIComponent(seriesId)}&order-series=${encodeURIComponent(orderSeries)}`,
|
body: `selection=${encodeURIComponent(seriesId)}&order-series=${encodeURIComponent(orderSeries)}`,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
const payload = contentType.includes('application/json') ? await response.json() : await response.text();
|
||||||
const statusEl = document.getElementById(statusId);
|
const statusEl = document.getElementById(statusId);
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.innerHTML = `Series ${seriesId}: ${text}`;
|
statusEl.innerHTML = `<span class="text-success">Series ${seriesId} imported.</span> ${formatImportResponse(payload)}`;
|
||||||
}
|
}
|
||||||
markSeriesImported(seriesId);
|
markSeriesImported(seriesId);
|
||||||
|
importedCount += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const statusEl = document.getElementById(statusId);
|
const statusEl = document.getElementById(statusId);
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.innerHTML = `Series ${seriesId}: <span style="color:red;">Failed</span>`;
|
statusEl.innerHTML = `Series ${seriesId}: <span class="text-danger">Failed</span>`;
|
||||||
}
|
}
|
||||||
|
failedCount += 1;
|
||||||
}
|
}
|
||||||
|
setImportProgress(i + 1, selectionList.length, `${importedCount} imported, ${failedCount} failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
statusDiv.innerHTML += '<div>All done.</div>';
|
appendImportStatus(`<div class="alert alert-info mb-0">All done. ${importedCount} imported, ${failedCount} failed.</div>`);
|
||||||
|
setImportProgress(selectionList.length, selectionList.length, 'Import complete');
|
||||||
|
setTimeout(function () {
|
||||||
|
setImportProgress(0, 0);
|
||||||
|
}, 1800);
|
||||||
|
setImportButtonsDisabled(false);
|
||||||
updateSelectionCount();
|
updateSelectionCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,12 +443,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if case %}
|
const importUrl = getImportUrl();
|
||||||
const importUrl = "{% url 'api-1:import_dicoms_case' case.id %}";
|
|
||||||
{% else %}
|
|
||||||
const importUrl = "{% url 'api-1:import_dicoms' %}";
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
await importSeriesList(toImport, importUrl, 'import-status');
|
await importSeriesList(toImport, importUrl, 'import-status');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -358,33 +505,13 @@
|
|||||||
const confirmMsg = `Import ${selectionList.length} series into case "${caseTitle}" (ID ${casePk})?`;
|
const confirmMsg = `Import ${selectionList.length} series into case "${caseTitle}" (ID ${casePk})?`;
|
||||||
if (!window.confirm(confirmMsg)) return;
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
// Build import URL and POST
|
const importUrl = getImportUrl(casePk);
|
||||||
const importTemplate = "{% url 'api-1:import_dicoms_case' 0 %}";
|
|
||||||
const importUrl = importTemplate.replace(/0\/?$/, casePk );
|
|
||||||
const form = new URLSearchParams();
|
|
||||||
for (const s of selectionList) form.append('selection', s);
|
|
||||||
const orderSeriesInput = document.querySelector('input[name="order-series"]:checked');
|
|
||||||
if (orderSeriesInput) form.append('order-series', orderSeriesInput.value);
|
|
||||||
const csrfToken = '{{ csrf_token }}';
|
|
||||||
|
|
||||||
const resp = await fetch(importUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'X-CSRFToken': csrfToken,
|
|
||||||
},
|
|
||||||
body: form.toString(),
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
// Close modal and show result in import-status
|
// Close modal and show result in import-status
|
||||||
const modalEl = document.getElementById('caseSelectModal');
|
const modalEl = document.getElementById('caseSelectModal');
|
||||||
if (modalEl && window.bootstrap && bootstrap.Modal) {
|
if (modalEl && window.bootstrap && bootstrap.Modal) {
|
||||||
bootstrap.Modal.getInstance(modalEl)?.hide();
|
bootstrap.Modal.getInstance(modalEl)?.hide();
|
||||||
}
|
}
|
||||||
const statusDiv = document.getElementById('import-status');
|
await importSeriesList(selectionList, importUrl, `import-case-${casePk}`);
|
||||||
if (statusDiv) statusDiv.innerHTML = `<div>Imported into case ${casePk}: ${text}</div>`;
|
|
||||||
setTimeout(function(){ location.reload(); }, 1200);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Import handler error', err);
|
console.error('Import handler error', err);
|
||||||
alert('Import failed — check console for details.');
|
alert('Import failed — check console for details.');
|
||||||
@@ -409,11 +536,32 @@
|
|||||||
const confirmMsg = `Import ${seriesUids.length} remaining series into case ${casePk}?`;
|
const confirmMsg = `Import ${seriesUids.length} remaining series into case ${casePk}?`;
|
||||||
if (!window.confirm(confirmMsg)) return;
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
const importTemplate = "{% url 'api-1:import_dicoms_case' 0 %}";
|
const importUrl = getImportUrl(casePk);
|
||||||
const importUrl = importTemplate.replace(/0\/?$/, casePk);
|
|
||||||
await importSeriesList(seriesUids, importUrl, `import-missing-${casePk}`);
|
await importSeriesList(seriesUids, importUrl, `import-missing-${casePk}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('click', async function (e) {
|
||||||
|
const trigger = e.target.closest('.import-partial-series-btn');
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
const seriesUids = (trigger.dataset.seriesUids || '')
|
||||||
|
.split(',')
|
||||||
|
.map(v => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!seriesUids.length) {
|
||||||
|
alert('No partially imported series were found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const casePk = trigger.dataset.caseId || importCaseId;
|
||||||
|
const confirmMsg = `Finish importing ${seriesUids.length} series${casePk ? ` into case ${casePk}` : ''}?`;
|
||||||
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
|
const importUrl = getImportUrl(casePk);
|
||||||
|
await importSeriesList(seriesUids, importUrl, `import-partial-${casePk || 'orphan'}`);
|
||||||
|
});
|
||||||
|
|
||||||
// Row click toggles selection when clicking any non-interactive part of the series card
|
// Row click toggles selection when clicking any non-interactive part of the series card
|
||||||
document.body.addEventListener('click', function (e) {
|
document.body.addEventListener('click', function (e) {
|
||||||
const item = e.target.closest('.series-item');
|
const item = e.target.closest('.series-item');
|
||||||
@@ -500,6 +648,39 @@
|
|||||||
box-shadow: 0 1px 0 rgba(255,255,255,0.02) inset;
|
box-shadow: 0 1px 0 rgba(255,255,255,0.02) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.study-block--partial-import {
|
||||||
|
border-color: rgba(245, 158, 11, 0.55);
|
||||||
|
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.14) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-toolbar {
|
||||||
|
top: 0.75rem;
|
||||||
|
z-index: 1030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-toolbar__inner {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(15, 17, 21, 0.94);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-settings summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-settings fieldset {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-settings legend {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.study-block summary { font-size: 1rem; cursor: pointer; padding: 4px 6px; color: #e6eef6; }
|
.study-block summary { font-size: 1rem; cursor: pointer; padding: 4px 6px; color: #e6eef6; }
|
||||||
|
|
||||||
.study-series-list { list-style: none; margin: 8px 0 0 0; padding: 0; }
|
.study-series-list { list-style: none; margin: 8px 0 0 0; padding: 0; }
|
||||||
@@ -540,6 +721,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.series-item--partial-import {
|
||||||
|
border-color: rgba(245, 158, 11, 0.7);
|
||||||
|
background: linear-gradient(180deg, rgba(245, 158, 11, 0.10), rgba(255, 255, 255, 0.00));
|
||||||
|
}
|
||||||
|
|
||||||
.series-item:hover { box-shadow: 0 6px 18px rgba(0,0,0,0.4); transform: translateY(-2px); }
|
.series-item:hover { box-shadow: 0 6px 18px rgba(0,0,0,0.4); transform: translateY(-2px); }
|
||||||
|
|
||||||
.series-left { display: block; }
|
.series-left { display: block; }
|
||||||
|
|||||||
+10
-1
@@ -2328,11 +2328,11 @@ def user_uploads(
|
|||||||
# Build per-study suggestions where some series are already in a case but
|
# Build per-study suggestions where some series are already in a case but
|
||||||
# additional uploaded series from the same study are still pending import.
|
# additional uploaded series from the same study are still pending import.
|
||||||
uploaded_series_uids = [series_uid for series_uid, _, _, _ in series_list]
|
uploaded_series_uids = [series_uid for series_uid, _, _, _ in series_list]
|
||||||
|
existing_series_by_uid: dict[str, list[Series]] = defaultdict(list)
|
||||||
|
|
||||||
if uploaded_series_uids:
|
if uploaded_series_uids:
|
||||||
existing_series = (
|
existing_series = (
|
||||||
Series.objects.filter(series_instance_uid__in=uploaded_series_uids)
|
Series.objects.filter(series_instance_uid__in=uploaded_series_uids)
|
||||||
.exclude(case=None)
|
|
||||||
.prefetch_related("case")
|
.prefetch_related("case")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2342,6 +2342,7 @@ def user_uploads(
|
|||||||
for existing in existing_series:
|
for existing in existing_series:
|
||||||
if not existing.series_instance_uid:
|
if not existing.series_instance_uid:
|
||||||
continue
|
continue
|
||||||
|
existing_series_by_uid[existing.series_instance_uid].append(existing)
|
||||||
for case_obj in existing.case.all():
|
for case_obj in existing.case.all():
|
||||||
if not case_obj.check_user_can_edit(request.user):
|
if not case_obj.check_user_can_edit(request.user):
|
||||||
continue
|
continue
|
||||||
@@ -2355,6 +2356,13 @@ def user_uploads(
|
|||||||
if not study_uploaded_uids:
|
if not study_uploaded_uids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
study_partial_imported_uids = sorted(
|
||||||
|
s_uid for s_uid in study_uploaded_uids if existing_series_by_uid.get(s_uid)
|
||||||
|
)
|
||||||
|
if study_partial_imported_uids:
|
||||||
|
study_data["partial_imported_uids"] = study_partial_imported_uids
|
||||||
|
study_data["partial_imported_count"] = len(study_partial_imported_uids)
|
||||||
|
|
||||||
for case_pk, imported_uids in case_to_series_uids.items():
|
for case_pk, imported_uids in case_to_series_uids.items():
|
||||||
overlap = study_uploaded_uids & imported_uids
|
overlap = study_uploaded_uids & imported_uids
|
||||||
if not overlap:
|
if not overlap:
|
||||||
@@ -2382,6 +2390,7 @@ def user_uploads(
|
|||||||
{
|
{
|
||||||
"series_list": series_list,
|
"series_list": series_list,
|
||||||
"grouped_series": grouped_series,
|
"grouped_series": grouped_series,
|
||||||
|
"uploaded_series_uids": uploaded_series_uids,
|
||||||
"case": case,
|
"case": case,
|
||||||
"user": user,
|
"user": user,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user