Add series images endpoint and template for HTMX integration
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div class="series-images-list">
|
||||||
|
{% for image in images %}
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
{% if image.image %}
|
||||||
|
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" target="_blank" class="me-2">
|
||||||
|
<img src="{{ image.image.url }}" alt="image-{{ image.pk }}" style="width:96px;height:auto;object-fit:cover;border-radius:.25rem;" class="img-thumbnail">
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="me-2 bg-secondary" style="width:96px;height:72px;border-radius:.25rem;"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div><strong>{{ image.image.name|default:"(no file)" }}</strong></div>
|
||||||
|
{% if image.image %}
|
||||||
|
<div class="small text-muted">{{ image.image.size|filesizeformat }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if image.image_blake3_hash %}
|
||||||
|
<div class="small text-muted">hash: {{ image.image_blake3_hash|truncatechars:12 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ms-auto text-end">
|
||||||
|
<a href="{% url 'atlas:series_image_dicom' pk=image.pk %}" class="btn btn-sm btn-outline-secondary">View DICOM</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No images found for this series.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -1,337 +1,361 @@
|
|||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||||
|
|
||||||
<div class="container my-4">
|
<div class="container my-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 mb-3">
|
<div class="col-12 mb-3">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
||||||
<div class="text-muted small">{{ series.plane }}, {{ series.contrast }}</div>
|
<div class="text-muted small">{{ series.plane }}, {{ series.contrast }}</div>
|
||||||
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
|
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
|
||||||
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||||
|
{% else %}
|
||||||
|
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-auto d-flex gap-2">
|
</div>
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||||
|
<div id="root" class="dicom-viewer-root w-100" data-images="{{ image_url_array_and_count.0 }}"
|
||||||
|
style="height: 600px; box-sizing: border-box; background: #222;" data-auto-cache-stack=false>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
{% if can_edit and editing_finding < 1 %}
|
||||||
|
<button id="add-finding-button" class="btn btn-primary btn-sm">Add finding</button>
|
||||||
|
<button id="clone-finding-button" class="btn btn-outline-primary btn-sm"
|
||||||
|
title="Clone an existing finding"
|
||||||
|
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
|
||||||
|
hx-target="#clone-findings-modal"
|
||||||
|
hx-trigger="click"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#clone-findings-modal">Clone finding</button>
|
||||||
|
{% endif %}
|
||||||
|
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<!-- Finding form moved below the viewport -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">Finding form</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="finding-form">
|
||||||
|
<div class="hide" id="hidden-form">
|
||||||
|
{% if editing_finding > 0 %}
|
||||||
|
<h5>Editing Finding</h5>
|
||||||
|
<p class="small">Editing finding with ID: {{editing_finding}}</p>
|
||||||
|
{% else %}
|
||||||
|
<h5>Add Finding</h5>
|
||||||
|
<p class="small">Click the button below to add a new finding.</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" id="series_finding_form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{series_finding_form|crispy}}
|
||||||
|
<div class="d-flex gap-2 mt-2">
|
||||||
|
<input type="submit" value="Submit" class="btn btn-success btn-sm">
|
||||||
|
<button id="cancel-add-finding-button" class="btn btn-secondary btn-sm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="styled-detail open">
|
||||||
|
<summary>Findings</summary>
|
||||||
|
<div class="mt-2">
|
||||||
|
{% for finding in series.findings.all %}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body d-flex flex-column flex-md-row justify-content-between gap-2">
|
||||||
|
<div>
|
||||||
|
<button id="finding-{{finding.pk}}-3d" class="btn btn-sm btn-outline-secondary view-finding-button-3d me-2"
|
||||||
|
data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>View 3D</button>
|
||||||
|
<strong>Finding(s):</strong>
|
||||||
|
{% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
|
||||||
|
<strong>Structure(s):</strong> {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
|
||||||
|
<strong>Condition(s):</strong> {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
|
||||||
|
<div class="mt-2">{{finding.description}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
{% if request.user.is_superuser %}
|
||||||
|
<button class="btn btn-sm btn-outline-dark mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#extra-{{finding.pk}}">Raw JSON</button>
|
||||||
|
<div class="collapse" id="extra-{{finding.pk}}">
|
||||||
|
<pre class="small bg-light p-2">{{finding.annotation_json}}</pre>
|
||||||
|
<pre class="small bg-light p-2">{{finding.viewport_json}}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="btn btn-sm btn-outline-primary mb-1">Edit</a>
|
||||||
|
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="btn btn-sm btn-outline-danger">Delete</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No findings available.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Images (loaded on demand via HTMX) -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Images</span>
|
||||||
|
<div>
|
||||||
|
<button id="load-images-button" class="btn btn-sm btn-outline-primary"
|
||||||
|
hx-get="{% url 'atlas:series_images' series.pk %}"
|
||||||
|
hx-target="#series-images-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-on="htmx:beforeRequest: document.getElementById('images-loading-indicator').style.display='inline-block'; htmx:afterSwap: document.getElementById('images-loading-indicator').style.display='none'; document.getElementById('load-images-button').disabled = true">
|
||||||
|
Load images
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'atlas:series_download' series.pk %}" class="btn btn-sm btn-outline-success ms-2" target="_blank" rel="noopener">Download</a>
|
||||||
|
<span id="images-loading-indicator" class="spinner-border spinner-border-sm ms-2" role="status" aria-hidden="true" style="display:none"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="series-images-container">
|
||||||
|
<p class="text-muted small">Click 'Load images' to fetch.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Cases</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="cases-list">
|
||||||
|
{% include 'atlas/partials/series_cases_list.html' %}
|
||||||
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
<details id="add-series-to-case-details" class="mt-2">
|
||||||
{% else %}
|
<summary>Add Series to Case</summary>
|
||||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}" hx-target="#cases-list" hx-swap="innerHTML" class="mt-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="case-select" class="form-label">Select Case</label>
|
||||||
|
<select name="case_id" id="case-select" class="form-select" required>
|
||||||
|
{% for case in available_cases %}
|
||||||
|
<option value="{{ case.pk }}">{{ case.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add to Case</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="document.getElementById('add-series-to-case-details').open = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Actions</div>
|
||||||
|
<div class="card-body d-flex flex-column gap-2">
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
|
||||||
|
<a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Truncate series</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p>
|
||||||
|
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Start</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
|
||||||
|
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">End</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
|
||||||
|
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
|
||||||
|
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
|
||||||
|
</div>
|
||||||
|
<div id="truncate-output" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-8 mb-3">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body p-2">
|
|
||||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
|
||||||
<div id="root" class="dicom-viewer-root w-100" data-images="{{ image_url_array_and_count.0 }}"
|
|
||||||
style="height: 600px; box-sizing: border-box; background: #222;" data-auto-cache-stack=false>
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
<div class="card-footer bg-white d-flex flex-wrap gap-2 align-items-center">
|
|
||||||
{% if can_edit and editing_finding < 1 %}
|
|
||||||
<button id="add-finding-button" class="btn btn-primary btn-sm">Add finding</button>
|
|
||||||
<button id="clone-finding-button" class="btn btn-outline-primary btn-sm"
|
|
||||||
title="Clone an existing finding"
|
|
||||||
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
|
|
||||||
hx-target="#clone-findings-modal"
|
|
||||||
hx-trigger="click"
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target="#clone-findings-modal">Clone finding</button>
|
|
||||||
{% endif %}
|
|
||||||
<div class="ms-auto text-muted small">{{ series.get_image_count }} images</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
||||||
<!-- Finding form moved below the viewport -->
|
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
||||||
<div class="card mt-3">
|
<div class="modal-content"></div>
|
||||||
<div class="card-header">Finding form</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="finding-form">
|
|
||||||
<div class="hide" id="hidden-form">
|
|
||||||
{% if editing_finding > 0 %}
|
|
||||||
<h5>Editing Finding</h5>
|
|
||||||
<p class="small">Editing finding with ID: {{editing_finding}}</p>
|
|
||||||
{% else %}
|
|
||||||
<h5>Add Finding</h5>
|
|
||||||
<p class="small">Click the button below to add a new finding.</p>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" id="series_finding_form">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{series_finding_form|crispy}}
|
|
||||||
<div class="d-flex gap-2 mt-2">
|
|
||||||
<input type="submit" value="Submit" class="btn btn-success btn-sm">
|
|
||||||
<button id="cancel-add-finding-button" class="btn btn-secondary btn-sm">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<details class="styled-detail open">
|
|
||||||
<summary>Findings</summary>
|
|
||||||
<div class="mt-2">
|
|
||||||
{% for finding in series.findings.all %}
|
|
||||||
<div class="card mb-2">
|
|
||||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between gap-2">
|
|
||||||
<div>
|
|
||||||
<button id="finding-{{finding.pk}}-3d" class="btn btn-sm btn-outline-secondary view-finding-button-3d me-2"
|
|
||||||
data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>View 3D</button>
|
|
||||||
<strong>Finding(s):</strong>
|
|
||||||
{% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
|
|
||||||
<strong>Structure(s):</strong> {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
|
|
||||||
<strong>Condition(s):</strong> {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
|
|
||||||
<div class="mt-2">{{finding.description}}</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-end">
|
|
||||||
{% if request.user.is_superuser %}
|
|
||||||
<button class="btn btn-sm btn-outline-dark mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#extra-{{finding.pk}}">Raw JSON</button>
|
|
||||||
<div class="collapse" id="extra-{{finding.pk}}">
|
|
||||||
<pre class="small bg-light p-2">{{finding.annotation_json}}</pre>
|
|
||||||
<pre class="small bg-light p-2">{{finding.viewport_json}}</pre>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if can_edit %}
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="btn btn-sm btn-outline-primary mb-1">Edit</a>
|
|
||||||
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="btn btn-sm btn-outline-danger">Delete</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<p class="text-muted">No findings available.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-4 mb-3">
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header">Cases</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="cases-list">
|
|
||||||
{% include 'atlas/partials/series_cases_list.html' %}
|
|
||||||
</div>
|
|
||||||
{% if can_edit %}
|
|
||||||
<details id="add-series-to-case-details" class="mt-2">
|
|
||||||
<summary>Add Series to Case</summary>
|
|
||||||
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}" hx-target="#cases-list" hx-swap="innerHTML" class="mt-2">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="mb-2">
|
|
||||||
<label for="case-select" class="form-label">Select Case</label>
|
|
||||||
<select name="case_id" id="case-select" class="form-select" required>
|
|
||||||
{% for case in available_cases %}
|
|
||||||
<option value="{{ case.pk }}">{{ case.title }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Add to Case</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="document.getElementById('add-series-to-case-details').open = false">Close</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header">Actions</div>
|
|
||||||
<div class="card-body d-flex flex-column gap-2">
|
|
||||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
|
|
||||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
|
|
||||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
|
|
||||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
|
|
||||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
|
|
||||||
<a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% if can_edit %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">Truncate series</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p>
|
|
||||||
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Start</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
|
|
||||||
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">End</label>
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
|
|
||||||
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
|
|
||||||
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
|
|
||||||
</div>
|
|
||||||
<div id="truncate-output" class="mt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
|
<script>
|
||||||
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
|
$(document).ready(function () {
|
||||||
<div class="modal-content"></div>
|
const apiKey = "root"
|
||||||
</div>
|
|
||||||
</div>
|
$("#set-lower-truncation-bound-button").click(() => {
|
||||||
|
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||||
|
});
|
||||||
|
$("#set-upper-truncation-bound-button").click(() => {
|
||||||
|
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
||||||
|
});
|
||||||
|
$("#truncate-test-button").click(() => {
|
||||||
|
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||||
|
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||||
|
const ok = window[`truncateStack_${apiKey}`](lower, upper);
|
||||||
|
if (ok) {
|
||||||
|
$("#truncate-output").text("The truncated images are shown above.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("#truncate-button").click(() => {
|
||||||
|
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
||||||
|
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
||||||
|
|
||||||
|
if (lower_bound >= upper_bound) {
|
||||||
|
alert("The lower bound must be less than the upper bound.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
|
||||||
|
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#truncate-output")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
<script>
|
})
|
||||||
$(document).ready(function () {
|
|
||||||
const apiKey = "root"
|
|
||||||
|
|
||||||
$("#set-lower-truncation-bound-button").click(() => {
|
$("#add-finding-button").click(() => {
|
||||||
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
$("#hidden-form").show()
|
||||||
});
|
|
||||||
$("#set-upper-truncation-bound-button").click(() => {
|
|
||||||
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
|
|
||||||
});
|
|
||||||
$("#truncate-test-button").click(() => {
|
|
||||||
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
|
||||||
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
|
||||||
const ok = window[`truncateStack_${apiKey}`](lower, upper);
|
|
||||||
if (ok) {
|
|
||||||
$("#truncate-output").text("The truncated images are shown above.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$("#truncate-button").click(() => {
|
|
||||||
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
|
|
||||||
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
|
|
||||||
|
|
||||||
if (lower_bound >= upper_bound) {
|
|
||||||
alert("The lower bound must be less than the upper bound.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
|
|
||||||
htmx.ajax("GET", `{% url 'api-1:series_truncate' series.id '****' '++++' %}`.replace("****", lower_bound).replace("++++", upper_bound), "#truncate-output")
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
$("#add-finding-button").click(() => {
|
|
||||||
$("#hidden-form").show()
|
|
||||||
//dicom_element = $(".cornerstone-element").get(0);
|
//dicom_element = $(".cornerstone-element").get(0);
|
||||||
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
|
||||||
//cornerstone.reset(dicom_element);
|
//cornerstone.reset(dicom_element);
|
||||||
////cornerstone.resize(dicom_element, true);
|
////cornerstone.resize(dicom_element, true);
|
||||||
$("#add-finding-button").hide()
|
$("#add-finding-button").hide()
|
||||||
$("#clone-finding-button").hide()
|
$("#clone-finding-button").hide()
|
||||||
//$("#finding-form").empty().append(
|
//$("#finding-form").empty().append(
|
||||||
// $("#hidden-form form").clone()
|
// $("#hidden-form form").clone()
|
||||||
//);
|
//);
|
||||||
|
|
||||||
|
});
|
||||||
|
$("#cancel-add-finding-button").click((e) => {
|
||||||
|
$("#hidden-form").hide()
|
||||||
|
$("#add-finding-button").show()
|
||||||
|
$("#clone-finding-button").show()
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
$("#reset-viewport-button").click(() => {
|
||||||
|
resetViewer_root();
|
||||||
|
|
||||||
|
});
|
||||||
|
$(".view-finding-button-3d").each((n, el) => {
|
||||||
|
$(el).click((e) => {
|
||||||
|
dicom_element = $(".cornerstone-element").get(0);
|
||||||
|
annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
|
||||||
|
viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
|
||||||
|
console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
|
||||||
|
importAnnotations_root(annotationjson3d);
|
||||||
|
importViewerState_root(viewer_state_json);
|
||||||
|
$(el).addClass("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
{% if editing_finding > 0 %}
|
||||||
|
$("#hidden-form").show()
|
||||||
|
setTimeout(function() {
|
||||||
|
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if request.GET.show_finding %}
|
||||||
|
function show_finding() {
|
||||||
|
console.log("show finding")
|
||||||
|
$("#finding-{{ request.GET.show_finding }}").trigger("click");
|
||||||
|
}
|
||||||
|
setTimeout(show_finding, 500);
|
||||||
|
{% endif %}
|
||||||
});
|
});
|
||||||
$("#cancel-add-finding-button").click((e) => {
|
|
||||||
$("#hidden-form").hide()
|
$(document).on('submit', '#series_finding_form', function (e) {
|
||||||
$("#add-finding-button").show()
|
|
||||||
$("#clone-finding-button").show()
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
$("#reset-viewport-button").click(() => {
|
type: 'POST',
|
||||||
resetViewer_root();
|
url: '{% url "atlas:add_finding" %}',
|
||||||
|
data: {
|
||||||
});
|
series_finding_id: {{ editing_finding }},
|
||||||
$(".view-finding-button-3d").each((n, el) => {
|
description: $('#id_description').val(),
|
||||||
$(el).click((e) => {
|
series: {{ series.pk }},
|
||||||
dicom_element = $(".cornerstone-element").get(0);
|
annotation_json_3d: window.exportAnnotations_root(),
|
||||||
annotationjson3d = JSON.parse(e.currentTarget.dataset.annotationjson3d);
|
viewer_state_3d: window.exportViewerState_root(),
|
||||||
viewer_state_json = JSON.parse(e.currentTarget.dataset.viewerstatejson);
|
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
|
||||||
console.log("Loading 3D annotation and viewer state", annotationjson3d, viewer_state_json);
|
.map((i, el) => {
|
||||||
importAnnotations_root(annotationjson3d);
|
return $(el).val()
|
||||||
importViewerState_root(viewer_state_json);
|
}).toArray()),
|
||||||
$(el).addClass("active");
|
structures: JSON.stringify($('#finding-form select[name="structures"]').find(
|
||||||
|
":selected")
|
||||||
|
.map((i, el) => {
|
||||||
|
return $(el).val()
|
||||||
|
}).toArray()),
|
||||||
|
conditions: JSON.stringify($('#finding-form select[name="conditions"]').find(
|
||||||
|
":selected")
|
||||||
|
.map((i, el) => {
|
||||||
|
return $(el).val()
|
||||||
|
}).toArray()),
|
||||||
|
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
|
||||||
|
action: 'post'
|
||||||
|
},
|
||||||
|
success: function (json) {
|
||||||
|
$("#finding-form").empty();
|
||||||
|
toastr.info('Finding added.');
|
||||||
|
location.href='{{series.get_absolute_url}}';
|
||||||
|
},
|
||||||
|
error: function (xhr, errmsg, err) {
|
||||||
|
console.log(xhr.status + ": " + xhr
|
||||||
|
.responseText); // provide a bit more info about the error to the console
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{% if editing_finding > 0 %}
|
|
||||||
$("#hidden-form").show()
|
|
||||||
setTimeout(function() {
|
|
||||||
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
{% if request.GET.show_finding %}
|
|
||||||
function show_finding() {
|
|
||||||
console.log("show finding")
|
|
||||||
$("#finding-{{ request.GET.show_finding }}").trigger("click");
|
|
||||||
}
|
|
||||||
setTimeout(show_finding, 500);
|
|
||||||
{% endif %}
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on('submit', '#series_finding_form', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: '{% url "atlas:add_finding" %}',
|
|
||||||
data: {
|
|
||||||
series_finding_id: {{ editing_finding }},
|
|
||||||
description: $('#id_description').val(),
|
|
||||||
series: {{ series.pk }},
|
|
||||||
annotation_json_3d: window.exportAnnotations_root(),
|
|
||||||
viewer_state_3d: window.exportViewerState_root(),
|
|
||||||
findings: JSON.stringify($('#finding-form select[name="findings"]').find(":selected")
|
|
||||||
.map((i, el) => {
|
|
||||||
return $(el).val()
|
|
||||||
}).toArray()),
|
|
||||||
structures: JSON.stringify($('#finding-form select[name="structures"]').find(
|
|
||||||
":selected")
|
|
||||||
.map((i, el) => {
|
|
||||||
return $(el).val()
|
|
||||||
}).toArray()),
|
|
||||||
conditions: JSON.stringify($('#finding-form select[name="conditions"]').find(
|
|
||||||
":selected")
|
|
||||||
.map((i, el) => {
|
|
||||||
return $(el).val()
|
|
||||||
}).toArray()),
|
|
||||||
csrfmiddlewaretoken: $('input[name=csrfmiddlewaretoken]').val(),
|
|
||||||
action: 'post'
|
|
||||||
},
|
|
||||||
success: function (json) {
|
|
||||||
$("#finding-form").empty();
|
|
||||||
toastr.info('Finding added.');
|
|
||||||
location.href='{{series.get_absolute_url}}';
|
|
||||||
},
|
|
||||||
error: function (xhr, errmsg, err) {
|
|
||||||
console.log(xhr.status + ": " + xhr
|
|
||||||
.responseText); // provide a bit more info about the error to the console
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
@@ -402,14 +426,14 @@
|
|||||||
transition: background 0.2s, font-size 0.2s;
|
transition: background 0.2s, font-size 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
details.styled-detail:not([open]) {
|
details.styled-detail:not([open]) {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
details.styled-detail:not([open]) > *:not(summary) {
|
details.styled-detail:not([open]) > *:not(summary) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
details.styled-detail:not([open]) > summary {
|
details.styled-detail:not([open]) > summary {
|
||||||
padding: 0.7em 1.2em 0.3em 1.2em;
|
padding: 0.7em 1.2em 0.3em 1.2em;
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ urlpatterns = [
|
|||||||
# TODO: case context series viewing (so that we can view series in the context of a case)
|
# TODO: case context series viewing (so that we can view series in the context of a case)
|
||||||
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
path("series/<int:pk>", views.series_detail, name="series_detail"),
|
||||||
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
|
||||||
|
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
|
||||||
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||||
path("series/", views.SeriesView.as_view(), name="series_view"),
|
path("series/", views.SeriesView.as_view(), name="series_view"),
|
||||||
|
|||||||
@@ -796,6 +796,17 @@ def series_detail(request, pk, finding_pk=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||||
|
def series_images_partial(request, pk):
|
||||||
|
"""Return a small fragment that lists images for a series. Intended for HTMX."""
|
||||||
|
series = get_object_or_404(Series, pk=pk)
|
||||||
|
logger.debug(series)
|
||||||
|
images = series.images.filter(removed=False).order_by("pk")
|
||||||
|
logger.debug(images)
|
||||||
|
return render(request, "atlas/partials/series_images.html", {"series": series, "images": images})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
# @user_is_atlas_editor
|
# @user_is_atlas_editor
|
||||||
def question_schema_detail(request, pk: int):
|
def question_schema_detail(request, pk: int):
|
||||||
|
|||||||
@@ -117,4 +117,6 @@ SECURE_HSTS_SECONDS = 0
|
|||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
"http://127.0.0.1:8000",
|
"http://127.0.0.1:8000",
|
||||||
"http://localhost:8000",
|
"http://localhost:8000",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
REMOTE_URL = "http://127.0.0.1:8000"
|
||||||
Reference in New Issue
Block a user