Compare commits

..
12 Commits
Author SHA1 Message Date
Ross a1aba7836a Fix link to series detail page in series viewer template 2025-11-14 22:11:47 +00:00
Ross 80b1ef3817 Enhance series case addition: update details element for better accessibility and close functionality 2025-11-14 21:56:40 +00:00
Ross 835d0382ba Refactor series cases handling: extract cases list to a partial and enhance HTMX integration for dynamic updates 2025-11-14 21:55:28 +00:00
Ross 8096a36a11 Reorganize finding form placement in series viewer for improved layout 2025-11-14 21:52:37 +00:00
Ross 97d65f0caa Refactor series viewer template for improved layout and usability; enhance findings display and action buttons 2025-11-14 21:48:06 +00:00
Ross b73e89393b . 2025-11-14 21:21:45 +00:00
Ross ec036a6266 Add open_access field to Resource model for access control 2025-11-14 21:16:55 +00:00
Ross 14e6514b90 Update ResourceDelete view to require AuthorOrChecker permissions for deletion 2025-11-14 21:14:58 +00:00
Ross 639e91fc1e Add created_date and modified_date fields to Resource model; update resource detail and list templates for improved display 2025-11-14 21:12:03 +00:00
Ross 74765e70a2 Add created_date and modified_date fields to Resource model; enhance resource navigation with dropdown menu in base template 2025-11-14 21:01:25 +00:00
Ross ca2b7feb5b Add Resource filtering and table display functionality 2025-11-14 20:56:49 +00:00
Ross 453fbf91be Add ManyToMany fields for sites and subspecialty in Resource model 2025-11-14 20:53:31 +00:00
12 changed files with 459 additions and 246 deletions
+21
View File
@@ -12,6 +12,8 @@ from .models import (
Structure,
Subspecialty,
NormalCase,
Resource,
Site,
)
from django.contrib.auth.models import User
from django.db.models import Q
@@ -335,6 +337,25 @@ class NormalCaseFilter(django_filters.FilterSet):
return queryset
return queryset.filter(age_days__lte=days)
class ResourceFilter(django_filters.FilterSet):
name = django_filters.CharFilter(field_name='name', lookup_expr='icontains')
site = django_filters.ModelMultipleChoiceFilter(field_name='sites', queryset=lambda request: Site.objects.all() if True else Site.objects.none())
subspecialty = django_filters.ModelMultipleChoiceFilter(field_name='subspecialty', queryset=Subspecialty.objects.all())
author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label='No author')
has_file = django_filters.BooleanFilter(method='filter_has_file', label='Has file')
class Meta:
model = Resource
fields = ('name', 'site', 'subspecialty', 'author')
def filter_has_file(self, queryset, name, value):
if value is True:
return queryset.exclude(file__isnull=True).exclude(file__exact='')
elif value is False:
return queryset.filter(file__isnull=True) | queryset.filter(file__exact='')
return queryset
class QuestionSchemaFilter(django_filters.FilterSet):
class Meta:
model = QuestionSchema
+1 -1
View File
@@ -665,7 +665,7 @@ CaseDifferentialFormSet = inlineformset_factory(
class ResourceForm(ModelForm):
class Meta:
model = Resource
exclude = ["author"]
exclude = ["author", "created_date"]
widgets = {
"description": Textarea(attrs={"cols": 80, "rows": 5}),
@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-11-14 20:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0081_remove_normalcase_age_years_normalcase_age_days'),
('generic', '0029_add_unique_constraint_supervisor_email'),
]
operations = [
migrations.AddField(
model_name='resource',
name='sites',
field=models.ManyToManyField(blank=True, help_text='The site(s) this resource is associated with (if any).', related_name='resources', to='generic.site'),
),
migrations.AddField(
model_name='resource',
name='subspecialty',
field=models.ManyToManyField(blank=True, help_text='Subspecialty or subspecialties this resource relates to.', to='atlas.subspecialty'),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-11-14 21:02
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0082_resource_sites_resource_subspecialty'),
]
operations = [
migrations.AddField(
model_name='resource',
name='created_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name='resource',
name='modified_date',
field=models.DateTimeField(auto_now=True),
),
]
+34 -1
View File
@@ -59,6 +59,7 @@ from generic.models import (
SeriesBase,
SeriesImageBase,
Modality,
Site,
UserUserGroup,
)
@@ -1875,6 +1876,27 @@ class Resource(models.Model, AuthorMixin):
help_text="Author of the resource",
related_name="resources",
)
created_date = models.DateTimeField(default=timezone.now)
modified_date = models.DateTimeField(auto_now=True)
# Link resources to one or more Sites (from generic.Site)
sites = models.ManyToManyField(
'generic.Site',
blank=True,
help_text='The site(s) this resource is associated with (if any).',
related_name='resources',
)
# Categorise resources by subspecialty
subspecialty = models.ManyToManyField(
Subspecialty,
blank=True,
help_text='Subspecialty or subspecialties this resource relates to.',
)
open_access = models.BooleanField(
default=True,
help_text="If true the resource is available to all users, otherwise only in the context of cases."
)
def __str__(self) -> str:
return self.name
@@ -1902,7 +1924,18 @@ class Resource(models.Model, AuthorMixin):
else:
html = self.name
return format_html("<details class='resource-block'><summary>{}</summary>{}</details>", self.name, mark_safe(html))
meta = ''
if self.sites.exists():
meta += ' Sites: ' + ', '.join([s.short_code or s.full_name for s in self.sites.all()])
if self.subspecialty.exists():
meta += ' Subspecialty: ' + ', '.join([str(s) for s in self.subspecialty.all()])
return format_html(
"<details class='resource-block'><summary>{}</summary><div>{}</div><div class='text-muted small'>{}</div></details>",
self.name,
mark_safe(html),
meta,
)
+13
View File
@@ -14,6 +14,7 @@ from .models import (
Structure,
Finding,
Subspecialty,
Resource,
)
from django.utils.html import format_html
@@ -414,6 +415,18 @@ class SubspecialtyTable(SelectionTable):
return format_html(record.get_synonym_link())
return f"{record.get_synonym_link()}"
class ResourceTable(SelectionTable):
name = tables.Column(linkify=("atlas:resource_detail", {"pk": tables.A("pk")}), verbose_name="Resource")
edit = tables.LinkColumn("atlas:resource_update", text="Edit", args=[A("pk")], orderable=False)
delete = tables.LinkColumn("atlas:resource_delete", text="Delete", args=[A("pk")], orderable=False)
class Meta(SelectionTable.Meta):
model = Resource
template_name = "django_tables2/bootstrap4.html"
fields = ("name", "description")
class CaseCollectionTable(SelectionTable):
edit = tables.LinkColumn(
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
+13 -2
View File
@@ -69,8 +69,19 @@
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
<li class="nav-item dropdown">
<a class="nav-link d-inline" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{% url 'atlas:resource_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Resources</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'atlas:resource_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Resource</a>
</li>
</ul>
</li>
<li class="nav-item">
@@ -0,0 +1,17 @@
{# Partial: series cases list. Expects `series` and `can_edit` in context. Returns the <ul> that is inserted into #cases-list. #}
<ul class="list-group list-group-flush">
{% for case in series.case.all %}
<li id="case-link-{{ case.pk }}" class="list-group-item d-flex justify-content-between align-items-center case-item">
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
{% if can_edit %}
<button hx-post="{% url 'atlas:remove_series_from_case' series.pk case.pk %}"
hx-target="#cases-list"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to remove this series from the case?"
class="btn btn-link btn-sm text-danger remove-button">Remove</button>
{% endif %}
</li>
{% empty %}
<li class="list-group-item">This series is not associated with any cases.</li>
{% endfor %}
</ul>
+82 -14
View File
@@ -5,32 +5,100 @@
{% include "atlas/resource_link_header.html" %}
{% endif %}
{{resource}}
<div class="container mt-3">
<div class="row">
<div class="col-lg-8">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ resource.name }}</h5>
<small class="text-muted">by {{ resource.get_authors|default:"Unknown" }}</small>
</div>
<div class="card-body">
{% if resource.description %}
<p class="card-text">{{ resource.description|linebreaksbr }}</p>
{% endif %}
Name: {{resource.name}}<br>
Description: {{resource.description}}<br>
URL: {{resource.url}}<br>
file: {{resource.file}}<br>
author: {{resource.get_authors}}<br>
<dl class="row">
{% if resource.url %}
<dt class="col-sm-3">URL</dt>
<dd class="col-sm-9"><a href="{{ resource.url }}" target="_blank" rel="noopener">{{ resource.url }}</a></dd>
{% endif %}
{{resource.case}}
{% if resource.file %}
<dt class="col-sm-3">File</dt>
<dd class="col-sm-9">
<a href="{{ resource.file.url }}" target="_blank" rel="noopener">Download</a>
{% if resource.file.name %}
<small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small>
{% endif %}
</dd>
{% endif %}
<div>
<a href="{% url 'atlas:resource_view' resource.pk %}" target="_blank">View Resource</a>
<dt class="col-sm-3">Source</dt>
<dd class="col-sm-9">{{ resource.get_display }}</dd>
</dl>
<div class="mt-3">
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:resource_view' resource.pk %}" target="_blank">Open resource page</a>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0">Used in cases</h6>
</div>
<div class="card-body">
{% if resource.case_set.all %}
<h3>Cases</h3>
This resource is used in the following cases:
<ul>
<ul class="list-unstyled mb-0">
{% for case in resource.case_set.all %}
<li><a href="{% url 'atlas:case_detail' case.id %}">{{ case.title }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>This resource is not used in any cases.</p>
<p class="mb-0 text-muted">This resource is not used in any cases.</p>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<aside class="card mb-3">
<div class="card-body">
<h6 class="card-title">Details</h6>
<ul class="list-unstyled small mb-0">
<li><strong>Author:</strong> {{ resource.get_authors|default:"-" }}</li>
<li><strong>Sites:</strong>
{% if resource.sites.all %}
{% for s in resource.sites.all %}
<div><a href="{% url 'generic:site_detail' s.pk %}">{{ s.name }}</a></div>
{% endfor %}
{% else %}
-
{% endif %}
</li>
<li class="mt-2"><strong>Subspecialty:</strong>
{% if resource.subspecialty.all %}
{% for sp in resource.subspecialty.all %}
<div>{{ sp.name }}</div>
{% endfor %}
{% else %}
-
{% endif %}
</li>
<li class="mt-2"><strong>Created:</strong> {{ resource.created_date|date:"Y-m-d H:i" }}</li>
<li><strong>Updated:</strong> {{ resource.modified_date|date:"Y-m-d H:i" }}</li>
</ul>
</div>
</aside>
<div class="d-grid gap-2">
<a class="btn btn-primary" href="{% url 'atlas:resource_update' resource.pk %}">Edit</a>
<a class="btn btn-outline-danger" href="{% url 'atlas:resource_delete' resource.pk %}">Delete</a>
<a class="btn btn-outline-secondary" href="{% url 'atlas:resource_view' %}">Back to resources</a>
</div>
</div>
</div>
</div>
{% endblock %}
+7 -8
View File
@@ -1,16 +1,15 @@
{% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content %}
<a href="{% url 'atlas:resource_create' %}">Create a new resource</a>
<h2>Resources</h2>
<ul id="resource-list">
{% for resource in object_list %}
<li class="">
<a href="{% url 'atlas:resource_detail' resource.pk %}">{{resource}}</a>
</li>
{% endfor %}
</ul>
<form>
{% render_table table %}
</form>
{% include "generic/partials/page_size_form.html" %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
{% endblock %}
+157 -181
View File
@@ -1,225 +1,199 @@
{% load crispy_forms_tags %}
<div>{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}</div>
<div>Description: {{series.description}}</div>
Associated case:
{% for case in series.case.all %}
<span id="case-link-{{ case.pk }}" class="case-item">
<a href="{% url 'atlas:case_detail' pk=case.pk %}">{{case}}</a>
{% if can_edit %}
<button hx-post="{% url 'atlas:remove_series_from_case' series.pk case.pk %}"
hx-target="#case-link-{{ case.pk }}"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to remove this series from the case?"
class="btn btn-link btn-sm button-unset remove-button">
Remove
</button>
{% endif %}
</span>
{% empty %}
This series is not associated with any cases.
{% if can_edit %}
<details>
<summary>Add Series to Case</summary>
<form hx-post="{% url 'atlas:add_series_to_case' series.pk %}"
hx-target="#add-series-to-case-result"
hx-swap="innerHTML">
{% csrf_token %}
<label for="case-select">Select Case:</label>
<select name="case_id" id="case-select" required>
{% for case in available_cases %}
<option value="{{ case.pk }}">{{ case.title }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Add to Case</button>
</form>
<div id="add-series-to-case-result"></div>
</details>
{% endif %}
{% endfor %}
<div>Author: {{ series.get_author_display }}</div>
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
{% comment %} <div id="single-dicom-viewer" class="dicom-viewer" data-images="{{ image_url_array_and_count.0 }}" data-annotations=''>
</div> {% endcomment %}
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
style="max-width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
data-auto-cache-stack=false
></div>
<div class="container my-4">
<div class="row">
<div class="col-12 mb-3">
<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>
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
<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 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 %}
{% if editing_finding < 1 %}
<button id="add-finding-button">Add finding</button>
<button id="clone-finding-button" title="Click to copy the details of a finding that is already associated with the series/case"
<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>
</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 existing finding</button>
data-bs-target="#clone-findings-modal">Clone finding</button>
{% endif %}
<button id="reset-viewport-button" class="btn btn-secondary">Reset viewport</button>
<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 %}
<h3>Editing Finding</h3>
<p>Editing finding with ID: {{editing_finding}}</p>
<h5>Editing Finding</h5>
<p class="small">Editing finding with ID: {{editing_finding}}</p>
{% else %}
<h3>Add Finding</h3>
<p>Click the button below to add a new finding.</p>
<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}}
<input type="submit" value="Submit" class="btn btn-lg btn-success fw-bold px-4 py-2" style="font-size: 1.2rem;">
<button id="cancel-add-finding-button">Cancel</button>
<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>
{% else %}
<button id="reset-viewport-button">Reset viewport</button>
{% endif %}
</div>
</div>
<details class="styled-detail open">
<summary>Findings</summary>
<div class="mt-2">
{% for finding in series.findings.all %}
<div class="finding-box">
<button id="finding-{{finding.pk}}-3d" class="view-finding-button-3d" data-annotationjson3d='{{finding.annotation_json_3d}}' data-viewerstatejson='{{finding.viewer_state_3d}}' data-findingid='{{finding.pk}}'>Click to view</button>
<span class="view-finding-details">
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br />
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br />
Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br />
Description: {{finding.description}}<br />
<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 %}
<span _="on click toggle .hidden on next .extra-details">+</span>
<div class="hidden extra-details">
<h4>Annotation JSON</h4>
<pre>{{finding.annotation_json}}</pre>
<h4>Viewport JSON</h4>
<pre>{{finding.viewport_json}}</pre>
<h4>Image ID</h4>
<pre>{{finding.current_image_id_index}}</pre>
<h4>3D Annotation JSON</h4>
<pre>{{finding.annotation_json_3d}}</pre>
<h4>Viewer State</h4>
<pre>{{finding.viewer_state_3d}}</pre>
<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 %}
</span>
{% if can_edit %}
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a>
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a>
<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>No findings available.</p>
<p class="text-muted">No findings available.</p>
{% endfor %}
</div>
</details>
{% if can_edit %}
<details class="styled-detail">
<summary>Truncate series</summary>
<p>
This will limit the series to the selected bounds (the rest of the images will be deleted). This is useful when you have a large volume of which only a small area is relevant to the case. NOTE: once deleted the images cannot be recovered on the site (they would have to be reuploaded if required). Make sure your images are shown in the correct order above and use test the test truncate button first.
</p>
<div role="alert" class="alert alert-warning">
<strong>Warning:</strong> Please note: if you have reordered the series this may result in the incorrect images being deleted.
</div>
</div>
Start <input id="lower-truncation-bound-input" type="number" value="1"> <button id="set-lower-truncation-bound-button">Set</button><br/>
End <input id="upper-truncation-bound-input"type="number" value="{{image_url_array_and_count.1}}"> <button id="set-upper-truncation-bound-button">Set</button><br/>
<button id="truncate-test-button">Test truncate</button>
<button id="truncate-button">Trucate series</button>
<div id="truncate-output"></div>
</details>
{% endif %}
{% endwith %}
{% if can_edit %}
<details class="styled-detail">
<summary>Series info</summary>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_anonymise_dicom' pk=series.pk %}'" title="Anonymise dicom images">
Anonymise dicoms
</button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom' pk=series.pk %}'" title="orders dicom by slice location">
Order by slice location
</button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_instance' pk=series.pk %}'" title="orders dicom by instance number">
Order by instance number
</button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}'" title="orders dicom by SeriesInstanceUID">
Order by SeriesInstanceUID
</button>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="window.location.href='{% url 'atlas:series_order_upload_filename' pk=series.pk %}'" title="orders dicom by uploaded filename">
Order by uploaded filename
</button>
<button type="button" class="btn btn-outline-warning btn-sm" onclick="window.location.href='{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}'" title="split series by dicom tag ImageType">
Split by ImageType tag
</button>
<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>
<details>
<summary>
Image details
</summary>
<form hx-post="{% url 'atlas:image_diff' %}"
hx-target="#temp"
>
{% for image in series.get_images %}
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}}
{% if image.image %}
[{{image.get_file_size|filesizeformat}}]
{% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}}
<input type="checkbox" name="image-id" value="{{image.id}}">
<br />
{% 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 %}
<button>Diff files</button>
</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>
<span id="temp"></span>
<span id="diff-controls">show <span id="diff-show-all" _="on click
log '1'
for row in .diff.rows
show row
end">all</span> <span id="diff-show-diff" _="
on click
log 'go'
for row in <.diff tr/>
log row.children[0].innerHTML
if row.children[0].innerHTML is empty
hide row
end
end
">diff</span></span>
</details>
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p>
<p>
<button type="button" class="btn btn-outline-success btn-sm" onclick="window.location.href='{% url 'atlas:series_download' pk=series.pk %}'">
Download images
</button>
</p>
</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>
<div id="clone-findings-modal"
class="modal modal-blur fade"
style="display: none"
aria-hidden="false"
tabindex="-1">
{% 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 id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
<div class="modal-content"></div>
</div>
@@ -359,6 +333,8 @@ Associated case:
});
</script>
{% endwith %}
<style>
.case-item {
transition: background-color 0.3s ease;
+32 -5
View File
@@ -33,6 +33,10 @@ from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMix
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from django.views.generic import ListView
from django_filters.views import FilterView
from django_tables2 import SingleTableMixin
from .filters import ResourceFilter
from .tables import ResourceTable
from django.views.decorators.csrf import csrf_exempt
from django.urls import reverse_lazy, reverse
@@ -1292,7 +1296,7 @@ class StructureDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
success_url = reverse_lazy("atlas:structure_view")
class ResourceDelete(LoginRequiredMixin, DeleteView):
class ResourceDelete(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Resource
template_name = "confirm_delete.html"
@@ -1311,9 +1315,22 @@ class ResourceCreate(LoginRequiredMixin, CreateView):
return super().form_valid(form)
class ResourceView(LoginRequiredMixin, ListView):
class ResourceView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Resource
# form_class = ResourceForm
table_class = ResourceTable
template_name = "atlas/resource_list.html"
filterset_class = ResourceFilter
def get_queryset(self):
# Base queryset
qs = Resource.objects.all()
# Atlas editors and superusers see everything
if self.request.user.is_superuser or self.request.user.groups.filter(name="atlas_editor").exists():
return qs
# Otherwise only show open access resources or those authored by the user
return qs.filter(Q(open_access=True) | Q(author__id=self.request.user.id)).distinct()
class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
@@ -4278,6 +4295,11 @@ def add_series_to_case(request, series_pk):
# Associate the series with the case
series.case.add(case)
# If this is an HTMX request, return the updated cases list partial so the
# client can replace the #cases-list innerHTML. Otherwise return a simple message.
if request.headers.get("Hx-Request") or request.META.get("HTTP_HX_REQUEST"):
return render(request, 'atlas/partials/series_cases_list.html', {'series': series, 'can_edit': case.check_user_can_edit(request.user)})
return HttpResponse(f"Series {series_pk} added to case {case.title} ({case_id}).")
return HttpResponse("Invalid request.")
@@ -4290,10 +4312,15 @@ def remove_series_from_case(request, series_pk, case_pk):
series = get_object_or_404(Series, pk=series_pk)
# Associate the series with the case
# Remove the association
series.case.remove(case)
return HttpResponse(f"Removed")
# If this is an HTMX request, return the updated cases list partial so the
# client can replace the #cases-list innerHTML. Otherwise return a simple message.
if request.headers.get("Hx-Request") or request.META.get("HTTP_HX_REQUEST"):
return render(request, 'atlas/partials/series_cases_list.html', {'series': series, 'can_edit': case.check_user_can_edit(request.user)})
return HttpResponse("Removed")
return HttpResponse("Invalid request.")
@login_required