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, Structure,
Subspecialty, Subspecialty,
NormalCase, NormalCase,
Resource,
Site,
) )
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
@@ -335,6 +337,25 @@ class NormalCaseFilter(django_filters.FilterSet):
return queryset return queryset
return queryset.filter(age_days__lte=days) 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 QuestionSchemaFilter(django_filters.FilterSet):
class Meta: class Meta:
model = QuestionSchema model = QuestionSchema
+1 -1
View File
@@ -665,7 +665,7 @@ CaseDifferentialFormSet = inlineformset_factory(
class ResourceForm(ModelForm): class ResourceForm(ModelForm):
class Meta: class Meta:
model = Resource model = Resource
exclude = ["author"] exclude = ["author", "created_date"]
widgets = { widgets = {
"description": Textarea(attrs={"cols": 80, "rows": 5}), "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, SeriesBase,
SeriesImageBase, SeriesImageBase,
Modality, Modality,
Site,
UserUserGroup, UserUserGroup,
) )
@@ -1875,6 +1876,27 @@ class Resource(models.Model, AuthorMixin):
help_text="Author of the resource", help_text="Author of the resource",
related_name="resources", 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: def __str__(self) -> str:
return self.name return self.name
@@ -1902,7 +1924,18 @@ class Resource(models.Model, AuthorMixin):
else: else:
html = self.name 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, Structure,
Finding, Finding,
Subspecialty, Subspecialty,
Resource,
) )
from django.utils.html import format_html from django.utils.html import format_html
@@ -414,6 +415,18 @@ class SubspecialtyTable(SelectionTable):
return format_html(record.get_synonym_link()) return format_html(record.get_synonym_link())
return f"{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): class CaseCollectionTable(SelectionTable):
edit = tables.LinkColumn( edit = tables.LinkColumn(
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False "atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
+13 -2
View File
@@ -69,8 +69,19 @@
<li class="nav-item"> <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> <a class="nav-link" href="{% url 'atlas:categories_list' %}"><i class="bi bi-tags me-1" aria-hidden="true"></i>Categories</a>
</li> </li>
<li class="nav-item"> <li class="nav-item dropdown">
<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> <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>
<li class="nav-item"> <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>
+91 -23
View File
@@ -5,32 +5,100 @@
{% include "atlas/resource_link_header.html" %} {% include "atlas/resource_link_header.html" %}
{% endif %} {% 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> <dl class="row">
Description: {{resource.description}}<br> {% if resource.url %}
URL: {{resource.url}}<br> <dt class="col-sm-3">URL</dt>
file: {{resource.file}}<br> <dd class="col-sm-9"><a href="{{ resource.url }}" target="_blank" rel="noopener">{{ resource.url }}</a></dd>
author: {{resource.get_authors}}<br> {% 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> <dt class="col-sm-3">Source</dt>
<a href="{% url 'atlas:resource_view' resource.pk %}" target="_blank">View Resource</a> <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 %}
<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 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> </div>
{% if resource.case_set.all %}
<h3>Cases</h3>
This resource is used in the following cases:
<ul>
{% 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>
{% endif %}
{% endblock %} {% endblock %}
+7 -8
View File
@@ -1,16 +1,15 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %}
{% block content %} {% block content %}
<a href="{% url 'atlas:resource_create' %}">Create a new resource</a>
<h2>Resources</h2> <h2>Resources</h2>
<ul id="resource-list"> <form>
{% for resource in object_list %} {% render_table table %}
<li class=""> </form>
<a href="{% url 'atlas:resource_detail' resource.pk %}">{{resource}}</a>
</li> {% include "generic/partials/page_size_form.html" %}
{% endfor %} {% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</ul>
{% endblock %} {% endblock %}
+182 -206
View File
@@ -1,225 +1,199 @@
{% load crispy_forms_tags %} {% 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 %} {% 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 }}" <div class="container my-4">
style="max-width: 1000px; height: 600px; box-sizing: border-box; background: #222;" <div class="row">
data-auto-cache-stack=false <div class="col-12 mb-3">
></div> <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>
{% if can_edit %} <h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
{% if editing_finding < 1 %} <div class="text-muted small">{{ series.plane }}, {{ series.contrast }}</div>
<button id="add-finding-button">Add finding</button> <div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
<button id="clone-finding-button" title="Click to copy the details of a finding that is already associated with the series/case" <div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
hx-get="{% url 'atlas:series_finding_related' series.pk %}" </div>
hx-target="#clone-findings-modal" <div class="ms-auto d-flex gap-2">
hx-trigger="click" <a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
data-bs-toggle="modal" {% if can_edit %}
data-bs-target="#clone-findings-modal" <button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
>Clone existing finding</button> {% else %}
{% endif %} <button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
<button id="reset-viewport-button" class="btn btn-secondary">Reset viewport</button> {% endif %}
<div id="finding-form"> </div>
<div class="hide" id="hidden-form"> </div>
{% if editing_finding > 0 %}
<h3>Editing Finding</h3>
<p>Editing finding with ID: {{editing_finding}}</p>
{% else %}
<h3>Add Finding</h3>
<p>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>
</form>
</div> </div>
</div> </div>
{% else %} </div>
<button id="reset-viewport-button">Reset viewport</button>
{% endif %}
<details class="styled-detail open"> <div class="row">
<summary>Findings</summary> <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>
{% for finding in series.findings.all %} <div class="mt-3">
<div class="finding-box"> <!-- Finding form moved below the viewport -->
<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> <div class="card mt-3">
<div class="card-header">Finding form</div>
<span class="view-finding-details"> <div class="card-body">
Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}<br /> <div id="finding-form">
Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}<br /> <div class="hide" id="hidden-form">
Condition(s): {% for s in finding.conditions.all %}{{s.get_link}}{% endfor %}<br /> {% if editing_finding > 0 %}
Description: {{finding.description}}<br /> <h5>Editing Finding</h5>
<p class="small">Editing finding with ID: {{editing_finding}}</p>
{% if request.user.is_superuser %} {% else %}
<span _="on click toggle .hidden on next .extra-details">+</span> <h5>Add Finding</h5>
<div class="hidden extra-details"> <p class="small">Click the button below to add a new finding.</p>
<h4>Annotation JSON</h4> {% endif %}
<pre>{{finding.annotation_json}}</pre> <form method="post" id="series_finding_form">
<h4>Viewport JSON</h4> {% csrf_token %}
<pre>{{finding.viewport_json}}</pre> {{series_finding_form|crispy}}
<h4>Image ID</h4> <div class="d-flex gap-2 mt-2">
<pre>{{finding.current_image_id_index}}</pre> <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>
<h4>3D Annotation JSON</h4> </div>
<pre>{{finding.annotation_json_3d}}</pre> </form>
<h4>Viewer State</h4> </div>
<pre>{{finding.viewer_state_3d}}</pre>
</div> </div>
{% endif %} </div>
</span> </div>
{% if can_edit %} <details class="styled-detail open">
<a href="{% url 'atlas:series_edit_finding' pk=series.pk finding_pk=finding.pk %}" class="edit-finding-link">Edit</a> <summary>Findings</summary>
<a href="{% url 'atlas:delete_finding' pk=finding.pk %}" class="delete-finding-link">Delete</a> <div class="mt-2">
{% endif %} {% 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>
{% empty %}
<p>No findings available.</p>
{% endfor %}
</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>
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> </div>
<details>
<summary> <div class="col-md-4 mb-3">
Image details <div class="card mb-3">
</summary> <div class="card-header">Cases</div>
<form hx-post="{% url 'atlas:image_diff' %}" <div class="card-body">
hx-target="#temp" <div id="cases-list">
> {% include 'atlas/partials/series_cases_list.html' %}
{% for image in series.get_images %} </div>
[{{ image.id }}] <a href='{% url "atlas:series_image_dicom" image.pk %}'>{{image.image.url}}</a>, pos: {{image.position}}, {{image.upload_filename}} {% if can_edit %}
{% if image.image %} <details id="add-series-to-case-details" class="mt-2">
[{{image.get_file_size|filesizeformat}}] <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 %} {% endif %}
{{image.image_md5_hash}} ({{image.is_dicom}}) {{image.image_blake3_hash}} </div>
<input type="checkbox" name="image-id" value="{{image.id}}"> </div>
<br />
{% endfor %}
<button>Diff files</button>
</form>
<span id="temp"></span>
<span id="diff-controls">show <span id="diff-show-all" _="on click <div class="card mb-3">
log '1' <div class="card-header">Actions</div>
for row in .diff.rows <div class="card-body d-flex flex-column gap-2">
show row <a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
end">all</span> <span id="diff-show-diff" _=" <a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
on click <a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
log 'go' <a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
for row in <.diff tr/> <a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
log row.children[0].innerHTML <a class="btn btn-outline-warning btn-sm" href="{% url 'api-1:series_split_by_tag' series.pk 'ImageType' %}">Split by ImageType tag</a>
if row.children[0].innerHTML is empty </div>
hide row </div>
end
end
">diff</span></span>
</details>
<p>Total image size: {{series.get_total_image_size|filesizeformat}}</p> {% if can_edit %}
<p> <div class="card">
<button type="button" class="btn btn-outline-success btn-sm" onclick="window.location.href='{% url 'atlas:series_download' pk=series.pk %}'"> <div class="card-header">Truncate series</div>
Download images <div class="card-body">
</button> <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>
</p> <div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
</details> <div class="mb-2">
{% endif %} <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" <div id="clone-findings-modal" class="modal modal-blur fade" tabindex="-1">
class="modal modal-blur fade"
style="display: none"
aria-hidden="false"
tabindex="-1">
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;"> <div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
<div class="modal-content"></div> <div class="modal-content"></div>
</div> </div>
@@ -359,6 +333,8 @@ Associated case:
}); });
</script> </script>
{% endwith %}
<style> <style>
.case-item { .case-item {
transition: background-color 0.3s ease; 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.edit import CreateView, UpdateView, DeleteView, FormView
from django.views.generic import ListView 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.views.decorators.csrf import csrf_exempt
from django.urls import reverse_lazy, reverse from django.urls import reverse_lazy, reverse
@@ -1292,7 +1296,7 @@ class StructureDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
success_url = reverse_lazy("atlas:structure_view") success_url = reverse_lazy("atlas:structure_view")
class ResourceDelete(LoginRequiredMixin, DeleteView): class ResourceDelete(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Resource model = Resource
template_name = "confirm_delete.html" template_name = "confirm_delete.html"
@@ -1311,9 +1315,22 @@ class ResourceCreate(LoginRequiredMixin, CreateView):
return super().form_valid(form) return super().form_valid(form)
class ResourceView(LoginRequiredMixin, ListView): class ResourceView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Resource 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): class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
@@ -4278,6 +4295,11 @@ def add_series_to_case(request, series_pk):
# Associate the series with the case # Associate the series with the case
series.case.add(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(f"Series {series_pk} added to case {case.title} ({case_id}).")
return HttpResponse("Invalid request.") 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) series = get_object_or_404(Series, pk=series_pk)
# Associate the series with the case # Remove the association
series.case.remove(case) 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.") return HttpResponse("Invalid request.")
@login_required @login_required