Compare commits
12
Commits
a2e9d5e681
...
a1aba7836a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1aba7836a | ||
|
|
80b1ef3817 | ||
|
|
835d0382ba | ||
|
|
8096a36a11 | ||
|
|
97d65f0caa | ||
|
|
b73e89393b | ||
|
|
ec036a6266 | ||
|
|
14e6514b90 | ||
|
|
639e91fc1e | ||
|
|
74765e70a2 | ||
|
|
ca2b7feb5b | ||
|
|
453fbf91be |
@@ -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
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user