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">
|
||||
<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>UID: <code class="series-uid">{{ series }}</code></div>
|
||||
{% if tags.SeriesNumber %}<div>Series #: <span class="series-number">{{ tags.SeriesNumber }}</span></div>{% endif %}
|
||||
@@ -26,6 +31,14 @@
|
||||
>View tags</a>
|
||||
{% endif %}
|
||||
</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 class="w-100 mt-2">
|
||||
<div id="series-tags-{{ series }}" class="series-tags-placeholder"></div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div id="series-list">
|
||||
{% if 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>
|
||||
<strong>{{ study.description|default:"(no description)" }}</strong>
|
||||
<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>
|
||||
</span>
|
||||
</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 %}
|
||||
<div class="alert alert-info py-2 mt-2 mb-2">
|
||||
<strong>Study already imported:</strong>
|
||||
@@ -51,7 +65,19 @@
|
||||
{% endif %}
|
||||
<ul class="study-series-list">
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</details>
|
||||
@@ -59,7 +85,11 @@
|
||||
{% else %}
|
||||
<ul>
|
||||
{% 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 %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -68,51 +98,79 @@
|
||||
{% if case %}
|
||||
<p>Importing into case: <a href='{% url "atlas:case_detail" case.pk %}'>{{case}}</a></p>
|
||||
{% endif %}
|
||||
<details><summary>Upload settings</summary>
|
||||
<form>
|
||||
<div>
|
||||
<fieldset><legend>Select how series should be ordered when uploaded</legend>
|
||||
<div class="helptext">
|
||||
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/>
|
||||
If you are not sure, leave this as the default.
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="order-series-slice-location" value="order-series-slice-location" name="order-series" checked>
|
||||
<label for="order-series-slice-location">Order series by slice location</label><br/>
|
||||
</div>
|
||||
<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><br/>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="order-series-none" value="order-series-none" name="order-series" >
|
||||
<label for="order-series-none">None</label><br/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="upload-toolbar sticky-top mb-3">
|
||||
<div class="upload-toolbar__inner">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-2">
|
||||
<div>
|
||||
<div id="selection-count" class="small text-muted">Selected: <span id="selected-count">0</span></div>
|
||||
<div class="small text-warning-emphasis">Keep this page open while imports are running.</div>
|
||||
</div>
|
||||
<details class="upload-settings">
|
||||
<summary>Upload settings</summary>
|
||||
<form class="mt-2">
|
||||
<fieldset>
|
||||
<legend>Select how series should be ordered when uploaded</legend>
|
||||
<div class="helptext">
|
||||
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.
|
||||
If you are not sure, leave this as the default.
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="order-series-slice-location" value="order-series-slice-location" name="order-series" checked>
|
||||
<label for="order-series-slice-location">Order series by slice location</label>
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<div id="selection-actions" class="d-flex align-items-center gap-2 mb-2">
|
||||
<div id="selection-count" class="small text-muted">Selected: <span id="selected-count">0</span></div>
|
||||
<button id="delete-uploads-button"
|
||||
hx-post="{% url 'api-1:clear_dicoms' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-confirm="This will clear selected uploads, Continue?"
|
||||
title="Deleted selected uploads"
|
||||
>Delete Uploads</button>
|
||||
<div id="import-progress" class="alert alert-warning d-none mb-3" role="alert">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||
<div>
|
||||
<strong>Import running.</strong>
|
||||
<span class="d-block">Do not leave this page until the progress bar completes.</span>
|
||||
</div>
|
||||
<div id="import-progress-text" class="small text-muted"></div>
|
||||
</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"
|
||||
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 id="selection-actions" class="d-flex flex-wrap align-items-center gap-2">
|
||||
<button id="delete-uploads-button"
|
||||
class="btn btn-outline-danger"
|
||||
hx-post="{% url 'api-1:clear_dicoms' %}"
|
||||
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 id="import-status"></div>
|
||||
|
||||
<div class="indicator"><div class="loader"></div>Loading...</div>
|
||||
<div id="import-status"></div>
|
||||
|
||||
<div class="imported">
|
||||
|
||||
@@ -165,6 +223,8 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const importCaseId = {% if case %}{{ case.pk }}{% else %}null{% endif %};
|
||||
|
||||
// Update the selected count shown beside action buttons
|
||||
function updateSelectionCount() {
|
||||
const all = Array.from(document.querySelectorAll('input[name="selection"]'));
|
||||
@@ -180,6 +240,73 @@
|
||||
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
|
||||
document.body.addEventListener('change', function (e) {
|
||||
if (e.target && e.target.name === 'selection') updateSelectionCount();
|
||||
@@ -218,6 +345,13 @@
|
||||
if (!li || li.classList.contains('imported')) return;
|
||||
|
||||
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')) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-success ms-2';
|
||||
@@ -240,15 +374,20 @@
|
||||
async function importSeriesList(selectionList, importUrl, statusPrefix) {
|
||||
const statusDiv = document.getElementById('import-status');
|
||||
if (!statusDiv) return;
|
||||
|
||||
statusDiv.innerHTML = '';
|
||||
setImportButtonsDisabled(true);
|
||||
setImportProgress(0, selectionList.length, 'Starting import');
|
||||
|
||||
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
|
||||
const csrfToken = '{{ csrf_token }}';
|
||||
let importedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (let i = 0; i < selectionList.length; i++) {
|
||||
const seriesId = selectionList[i];
|
||||
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 {
|
||||
const response = await fetch(importUrl, {
|
||||
method: 'POST',
|
||||
@@ -259,21 +398,34 @@
|
||||
body: `selection=${encodeURIComponent(seriesId)}&order-series=${encodeURIComponent(orderSeries)}`,
|
||||
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);
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = `Series ${seriesId}: ${text}`;
|
||||
statusEl.innerHTML = `<span class="text-success">Series ${seriesId} imported.</span> ${formatImportResponse(payload)}`;
|
||||
}
|
||||
markSeriesImported(seriesId);
|
||||
importedCount += 1;
|
||||
} catch (e) {
|
||||
const statusEl = document.getElementById(statusId);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -291,12 +443,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
{% if case %}
|
||||
const importUrl = "{% url 'api-1:import_dicoms_case' case.id %}";
|
||||
{% else %}
|
||||
const importUrl = "{% url 'api-1:import_dicoms' %}";
|
||||
{% endif %}
|
||||
|
||||
const importUrl = getImportUrl();
|
||||
await importSeriesList(toImport, importUrl, 'import-status');
|
||||
});
|
||||
}
|
||||
@@ -358,33 +505,13 @@
|
||||
const confirmMsg = `Import ${selectionList.length} series into case "${caseTitle}" (ID ${casePk})?`;
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
|
||||
// Build import URL and POST
|
||||
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();
|
||||
const importUrl = getImportUrl(casePk);
|
||||
// Close modal and show result in import-status
|
||||
const modalEl = document.getElementById('caseSelectModal');
|
||||
if (modalEl && window.bootstrap && bootstrap.Modal) {
|
||||
bootstrap.Modal.getInstance(modalEl)?.hide();
|
||||
}
|
||||
const statusDiv = document.getElementById('import-status');
|
||||
if (statusDiv) statusDiv.innerHTML = `<div>Imported into case ${casePk}: ${text}</div>`;
|
||||
setTimeout(function(){ location.reload(); }, 1200);
|
||||
await importSeriesList(selectionList, importUrl, `import-case-${casePk}`);
|
||||
} catch (err) {
|
||||
console.error('Import handler error', err);
|
||||
alert('Import failed — check console for details.');
|
||||
@@ -409,11 +536,32 @@
|
||||
const confirmMsg = `Import ${seriesUids.length} remaining series into case ${casePk}?`;
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
|
||||
const importTemplate = "{% url 'api-1:import_dicoms_case' 0 %}";
|
||||
const importUrl = importTemplate.replace(/0\/?$/, casePk);
|
||||
const importUrl = getImportUrl(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
|
||||
document.body.addEventListener('click', function (e) {
|
||||
const item = e.target.closest('.series-item');
|
||||
@@ -500,6 +648,39 @@
|
||||
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-series-list { list-style: none; margin: 8px 0 0 0; padding: 0; }
|
||||
@@ -540,6 +721,11 @@
|
||||
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-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
|
||||
# additional uploaded series from the same study are still pending import.
|
||||
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:
|
||||
existing_series = (
|
||||
Series.objects.filter(series_instance_uid__in=uploaded_series_uids)
|
||||
.exclude(case=None)
|
||||
.prefetch_related("case")
|
||||
)
|
||||
|
||||
@@ -2342,6 +2342,7 @@ def user_uploads(
|
||||
for existing in existing_series:
|
||||
if not existing.series_instance_uid:
|
||||
continue
|
||||
existing_series_by_uid[existing.series_instance_uid].append(existing)
|
||||
for case_obj in existing.case.all():
|
||||
if not case_obj.check_user_can_edit(request.user):
|
||||
continue
|
||||
@@ -2355,6 +2356,13 @@ def user_uploads(
|
||||
if not study_uploaded_uids:
|
||||
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():
|
||||
overlap = study_uploaded_uids & imported_uids
|
||||
if not overlap:
|
||||
@@ -2382,6 +2390,7 @@ def user_uploads(
|
||||
{
|
||||
"series_list": series_list,
|
||||
"grouped_series": grouped_series,
|
||||
"uploaded_series_uids": uploaded_series_uids,
|
||||
"case": case,
|
||||
"user": user,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user