Compare commits
38
Commits
02e844006d
...
206f0afd32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
206f0afd32 | ||
|
|
0e7307de18 | ||
|
|
09d9befceb | ||
|
|
62c26d199e | ||
|
|
cbf4c3497d | ||
|
|
a269cf1f52 | ||
|
|
57a3b02da3 | ||
|
|
8e8041cdc4 | ||
|
|
ad4b06d691 | ||
|
|
b1f0f9932f | ||
|
|
b32d265c2c | ||
|
|
0de7c345a2 | ||
|
|
77edb83688 | ||
|
|
f921a46b5f | ||
|
|
7116983e3d | ||
|
|
c0d71dfabc | ||
|
|
55371a9d0b | ||
|
|
6bc43135b9 | ||
|
|
b294cf310f | ||
|
|
3805c701d3 | ||
|
|
76d0db0e94 | ||
|
|
dca4ff249a | ||
|
|
f4cc3cc5b4 | ||
|
|
1dc02d1fc9 | ||
|
|
ab89ca22c9 | ||
|
|
ab29a7adf6 | ||
|
|
937edde023 | ||
|
|
299ac317e3 | ||
|
|
c45410b5e5 | ||
|
|
11cd044485 | ||
|
|
4a3055cab8 | ||
|
|
945a6e5e86 | ||
|
|
85229124de | ||
|
|
2676cb49a1 | ||
|
|
15791fa883 | ||
|
|
15c243172d | ||
|
|
293b5b6682 | ||
|
|
82ce6b0f93 |
@@ -176,37 +176,6 @@ $(document).ready(function () {
|
||||
});
|
||||
|
||||
|
||||
// Table row selection
|
||||
// Delegate click event to all table rows
|
||||
document.querySelectorAll('.row-selector tbody tr').forEach(function(row) {
|
||||
row.addEventListener('click', function(e) {
|
||||
// Ignore clicks on links or checkboxes
|
||||
if (e.target.tagName === 'A' || e.target.tagName === 'INPUT') return;
|
||||
|
||||
// Find the checkbox in this row
|
||||
const checkbox = row.querySelector('input[name="selection"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
row.classList.toggle('selected', checkbox.checked);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep row visually in sync with checkbox state (e.g. after page reload)
|
||||
const checkbox = row.querySelector('input[name="selection"]');
|
||||
if (checkbox && checkbox.checked) {
|
||||
row.classList.add('selected');
|
||||
}
|
||||
// Also toggle row selection when checkbox is clicked directly
|
||||
if (checkbox) {
|
||||
checkbox.addEventListener('click', function(e) {
|
||||
row.classList.toggle('selected', checkbox.checked);
|
||||
// Prevent row click event from firing
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
window.loadDicomViewerEvent = new Event('loadDicomViewer');
|
||||
|
||||
+22
-41
@@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import A
|
||||
|
||||
from generic.tables import SeriesImageColumn
|
||||
from generic.tables import SeriesImageColumn, SelectionTable
|
||||
|
||||
from .models import (
|
||||
Case,
|
||||
@@ -25,7 +25,7 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
|
||||
from django.db.models import Prefetch
|
||||
|
||||
|
||||
class CaseTable(tables.Table):
|
||||
class CaseTable(SelectionTable):
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
# Optimize prefetching for casecollection_set
|
||||
#case_collections_prefetch = Prefetch(
|
||||
@@ -174,9 +174,8 @@ class CaseTable(tables.Table):
|
||||
"atlas:case_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Case
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
@@ -190,7 +189,6 @@ class CaseTable(tables.Table):
|
||||
)
|
||||
sequence = ("view", )
|
||||
order_by = "-created_date"
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
|
||||
@@ -205,7 +203,7 @@ class PopupLinkColumn(tables.Column):
|
||||
return obj.get_thumbnail()[0]
|
||||
|
||||
|
||||
class SeriesTable(tables.Table):
|
||||
class SeriesTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -219,13 +217,12 @@ class SeriesTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:series_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
case = tables.ManyToManyColumn(verbose_name="Cases")
|
||||
findings = tables.ManyToManyColumn(verbose_name="Findings")
|
||||
structures = tables.ManyToManyColumn(verbose_name="Structures")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Series
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
@@ -240,7 +237,6 @@ class SeriesTable(tables.Table):
|
||||
)
|
||||
sequence = ("view", "popup", "images", "case")
|
||||
order_by = "-created_date"
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -259,7 +255,7 @@ class SeriesTable(tables.Table):
|
||||
)
|
||||
|
||||
|
||||
class ConditionTable(tables.Table):
|
||||
class ConditionTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:condition_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Condition",
|
||||
@@ -271,18 +267,16 @@ class ConditionTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:condition_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
|
||||
synonym = tables.Column(empty_values=())
|
||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Condition
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -299,7 +293,7 @@ class ConditionTable(tables.Table):
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
|
||||
class FindingTable(tables.Table):
|
||||
class FindingTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:finding_detail", {"pk": tables.A("pk")}), verbose_name="Finding"
|
||||
)
|
||||
@@ -309,25 +303,23 @@ class FindingTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:finding_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
|
||||
synonym = tables.Column(empty_values=())
|
||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Finding
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
|
||||
|
||||
class StructureTable(tables.Table):
|
||||
class StructureTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:structure_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Structure",
|
||||
@@ -338,25 +330,23 @@ class StructureTable(tables.Table):
|
||||
delete = tables.LinkColumn(
|
||||
"atlas:structure_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
|
||||
synonym = tables.Column(empty_values=())
|
||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Structure
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "primary")
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
|
||||
|
||||
class PresentationTable(tables.Table):
|
||||
class PresentationTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:presentation_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Presentation",
|
||||
@@ -368,20 +358,19 @@ class PresentationTable(tables.Table):
|
||||
# delete = tables.LinkColumn(
|
||||
# "atlas:presentation_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
# )
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
subspecialty = tables.ManyToManyColumn(verbose_name="Subspecialty")
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Presentation
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
|
||||
class PathologicalProcessTable(tables.Table):
|
||||
|
||||
class PathologicalProcessTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:pathological_process_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="PathologicalProcess",
|
||||
@@ -393,18 +382,15 @@ class PathologicalProcessTable(tables.Table):
|
||||
# delete = tables.LinkColumn(
|
||||
# "atlas:pathological_process_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
# )
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = PathologicalProcess
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name",)
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
|
||||
class SubspecialtyTable(tables.Table):
|
||||
class SubspecialtyTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="Subspecialty",
|
||||
@@ -416,21 +402,19 @@ class SubspecialtyTable(tables.Table):
|
||||
# delete = tables.LinkColumn(
|
||||
# "atlas:subspecialty_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
# )
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Subspecialty
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ()
|
||||
sequence = ("name",)
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def render_synonym(self, value, record):
|
||||
return format_html(record.get_synonym_link())
|
||||
return f"{record.get_synonym_link()}"
|
||||
|
||||
class CaseCollectionTable(tables.Table):
|
||||
class CaseCollectionTable(SelectionTable):
|
||||
edit = tables.LinkColumn(
|
||||
"atlas:exam_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
@@ -446,9 +430,8 @@ class CaseCollectionTable(tables.Table):
|
||||
|
||||
#differential = tables.ManyToManyColumn(verbose_name="Differential")
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = CaseCollection
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
@@ -459,7 +442,6 @@ class CaseCollectionTable(tables.Table):
|
||||
"author",
|
||||
)
|
||||
sequence = ("view", )#, "series")
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -471,7 +453,7 @@ class CaseCollectionTable(tables.Table):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
class QuestionSchemaTable(tables.Table):
|
||||
class QuestionSchemaTable(SelectionTable):
|
||||
name = tables.Column(
|
||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||
verbose_name="QuestionSchema",
|
||||
@@ -484,11 +466,10 @@ class QuestionSchemaTable(tables.Table):
|
||||
"atlas:question_schema_delete", text="Delete", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = QuestionSchema
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = ("name", "description", "schema")
|
||||
attrs = {"class": "table row-selector"}
|
||||
|
||||
|
||||
def render_schema(self, value, record):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Search results for "{{ query }}"</h5>
|
||||
|
||||
{% for group_name, items in results.items %}
|
||||
{% if items.exists %}
|
||||
<h6 class="mt-3">{{ group_name|capfirst }}</h6>
|
||||
<ul class="list-unstyled">
|
||||
{% for item in items %}
|
||||
<li>
|
||||
{% if group_name == 'conditions' %}
|
||||
<a href="{% url 'atlas:condition_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'findings' %}
|
||||
<a href="{% url 'atlas:finding_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'structures' %}
|
||||
<a href="{% url 'atlas:structure_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'subspecialties' %}
|
||||
<a href="{% url 'atlas:subspecialty_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% elif group_name == 'presentations' %}
|
||||
<a href="{% url 'atlas:presentation_detail' item.pk %}">{{ item.name }}</a>
|
||||
{% else %}
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not results.conditions.exists and not results.findings.exists and not results.structures.exists and not results.subspecialties.exists and not results.presentations.exists %}
|
||||
<p class="text-muted">No matches found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,68 +15,68 @@
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}">Collections</a>
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:collection_view' %}"><i class="bi bi-collection me-1" aria-hidden="true"></i>Collections</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:user_collections' %}">Collections to view / take</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:user_collections' %}"><i class="bi bi-eye me-1" aria-hidden="true"></i>Collections to view / take</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:collection_view' %}">Manage Collections</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:collection_view' %}"><i class="bi bi-gear me-1" aria-hidden="true"></i>Manage Collections</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:exam_create' %}">Create collection</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:exam_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create collection</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:case_view' %}">Cases</a>
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:case_view' %}"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Cases</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:case_view' %}">Manage Cases</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_view' %}"><i class="bi bi-list-ul me-1" aria-hidden="true"></i>Manage Cases</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_create' %}">Create Case</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:series_view' %}">Series</a>
|
||||
<a class="nav-link d-inline" href="{% url 'atlas:series_view' %}"><i class="bi bi-collection-play me-1" aria-hidden="true"></i>Series</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:series_view' %}">Manage Series</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:series_view' %}"><i class="bi bi-gear me-1" aria-hidden="true"></i>Manage Series</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'atlas:series_create' %}">Create Series</a>
|
||||
<a class="dropdown-item" href="{% url 'atlas:series_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Series</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:categories_list' %}">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 class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources">Resources</a>
|
||||
<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>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads">Uploads</a>
|
||||
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas">Question Schemas</a>
|
||||
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages">Help</a>
|
||||
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -108,16 +108,47 @@
|
||||
|
||||
|
||||
{% else %}
|
||||
<a href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">View case in OHIF</a> <a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">(new tab)</a>
|
||||
<div class="btn-group link-buttons" role="group">
|
||||
<button type="button" class="btn btn-primary" onclick="window.location.href='/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}'">View case in OHIF</button>
|
||||
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="visually-hidden">Toggle dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open in new tab</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="clearfix mb-2">
|
||||
<div class="float-start">
|
||||
<span class="id">ID: {{ case.id }}</span>
|
||||
<span class="mx-1">/</span>
|
||||
<span class="date">{{ case.created_date|date:"d/m/Y" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="date">
|
||||
{{ case.created_date|date:"d/m/Y" }}
|
||||
</div>
|
||||
<div class="id">
|
||||
ID: {{ case.id }}
|
||||
<div class="float-end text-end">
|
||||
{# Subspecialty badges (preserve any anchor returned by get_link) #}
|
||||
{% if case.subspecialty.all %}
|
||||
{% for sub in case.subspecialty.all %}
|
||||
<span class="badge bg-secondary ms-1">{{ sub.get_link|safe }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted me-2">No subspecialty</span>
|
||||
{% endif %}
|
||||
|
||||
{# Pathological process badges #}
|
||||
{% if case.pathological_process.all %}
|
||||
{% for p in case.pathological_process.all %}
|
||||
<span class="badge bg-secondary ms-1">{{ p.get_link|safe }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Diagnostic certainty as a badge #}
|
||||
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details id="dicom-viewer-details">
|
||||
<summary>Viewer</summary>
|
||||
@@ -140,11 +171,32 @@
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<p class="pre-whitespace"><b>History:</b> {{ case.history |linebreaks}}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion |linebreaks}}</p>
|
||||
|
||||
<p class="pre-whitespace"><b>Report:</b> {{ case.report |linebreaks}}</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">History</div>
|
||||
<div class="card-body">
|
||||
<div class="pre-whitespace">{{ case.history|linebreaks }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Discussion</div>
|
||||
<div class="card-body">
|
||||
<div class="pre-whitespace">{{ case.discussion|linebreaks }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Report</div>
|
||||
<div class="card-body">
|
||||
<div class="pre-whitespace">{{ case.report|linebreaks }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %}
|
||||
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
|
||||
{% if caseresource.pre_review %}
|
||||
@@ -157,7 +209,9 @@
|
||||
|
||||
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
||||
<form>
|
||||
{% partial case-series %}
|
||||
<div class="d-flex flex-wrap gap-2 align-items-start" id="case-series-row">
|
||||
{% partial case-series %}
|
||||
</div>
|
||||
</form>
|
||||
<span>
|
||||
<button type="button"
|
||||
@@ -227,7 +281,7 @@
|
||||
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
|
||||
|
||||
<div class="atlas-answer">
|
||||
<p class="pre-whitespace">
|
||||
<div class="pre-whitespace">
|
||||
Condition:
|
||||
<ul>
|
||||
{% for con in case.condition.all %}
|
||||
@@ -373,21 +427,9 @@
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Subspecialty:
|
||||
<ul>
|
||||
{% for sub in case.subspecialty.all %}
|
||||
<li>{{sub.get_link}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<b>Pathological Process:</b>
|
||||
<ul>
|
||||
{% for p in case.pathological_process.all %}
|
||||
<li>{{p.get_link}}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<b>Diagnostic certainty:</b> {{case.get_diagnostic_certainty_display}}<br />
|
||||
|
||||
<b>Collections:</b>
|
||||
|
||||
{% if can_edit %}
|
||||
@@ -728,6 +770,14 @@
|
||||
background-color: blue;
|
||||
transition: background-color 0.3s ease, border 0.3s ease;
|
||||
}
|
||||
/* Make series blocks act as flex items and wrap neatly on small screens */
|
||||
.series-block {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
min-width: 180px;
|
||||
max-width: 320px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
#series-reorder-list .dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -740,4 +790,7 @@
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.link-buttons {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,109 @@
|
||||
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 mb-3">
|
||||
{# HTMX loading indicator styles: hide by default, show when HTMX adds the .htmx-request class #}
|
||||
<style>
|
||||
#category-search-loading { display: none; }
|
||||
#category-search-loading.htmx-request { display: inline-block; }
|
||||
</style>
|
||||
|
||||
<p>Alternatively you can use the search function below</p>
|
||||
|
||||
<form method="get" class="d-flex flex-column" action="" id="category-search-form">
|
||||
<div class="d-flex align-items-center">
|
||||
<input id="category-search-input" type="search" name="q" placeholder="Search all categories (conditions, findings, structures...)" value="{{ query|default:'' }}" class="form-control me-2"
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary ms-2" type="submit">Search</button>
|
||||
</div>
|
||||
|
||||
{# Collapsed advanced filters block - defaults to collapsed #}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#category-filters" aria-expanded="false" aria-controls="category-filters">
|
||||
Advanced filters ▾
|
||||
</button>
|
||||
|
||||
<div class="collapse mt-2" id="category-filters">
|
||||
<div class="card card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-conditions" name="types" value="conditions" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-conditions">Conditions</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-findings" name="types" value="findings" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-findings">Findings</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-structures" name="types" value="structures" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-structures">Structures</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-subspecialties" name="types" value="subspecialties" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-subspecialties">Subspecialties</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="type-presentations" name="types" value="presentations" checked
|
||||
hx-get="{% url 'atlas:categories_search_partial' %}"
|
||||
hx-target="#category-search-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#category-search-form"
|
||||
hx-indicator="#category-search-loading" />
|
||||
<label class="form-check-label" for="type-presentations">Presentations</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Spinner shown while HTMX request is in-flight (selector matched by hx-indicator) #}
|
||||
<div id="category-search-loading" role="status" aria-hidden="true" class="ms-2">
|
||||
<span class="spinner-border spinner-border-sm text-primary" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
{% comment %} <button class="btn btn-primary ms-2" type="submit">Search</button> {% endcomment %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="category-search-results">
|
||||
{% if results is not None %}
|
||||
{% include 'atlas/_categories_search_results.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
||||
</p>
|
||||
|
||||
@@ -1,96 +1,157 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:condition_update' pk=condition.pk %}" title="Edit the Condition">Edit</a>
|
||||
<a href="{% url 'atlas:condition_delete' pk=condition.pk %}" title="Delete the Condition">Delete</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:atlas_condition_change' condition.id %}"
|
||||
title="Edit the Condition using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<h3 class="mb-0">{{ condition.name }}</h3>
|
||||
<div class="btn-group" role="group" aria-label="Condition actions">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:condition_update' pk=condition.pk %}">Edit</a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="{% url 'atlas:condition_delete' pk=condition.pk %}">Delete</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:atlas_condition_change' condition.id %}">Admin</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Name: {{condition.name}}</h3>
|
||||
Primary name: {{condition.primary}}<br />
|
||||
Subspecialty: {% for sub in condition.subspecialty.all %}
|
||||
<a href="{{sub.get_absolute_url}}">{{sub}}</a>
|
||||
{% endfor %}<br />
|
||||
Synonyms:
|
||||
{% for syn in condition.synonym.all %}
|
||||
<a href="{{syn.get_absolute_url}}">{{syn}}</a>
|
||||
{% endfor %}<br />
|
||||
Parent:
|
||||
{% for parent in condition.parent.all %}
|
||||
<a href="{{parent.get_absolute_url}}">{{parent}}</a>
|
||||
{% endfor %}<br />
|
||||
Children:
|
||||
{% for child in condition.child.all %}
|
||||
<a href="{{child.get_absolute_url}}">{{child}}</a>
|
||||
{% endfor %}<br />
|
||||
RCR condition: {{condition.rcr_curriculum}}<br/>
|
||||
|
||||
{% if not condition.rcr_curriculum %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<section class="mb-3">
|
||||
<h5 class="mb-2">Details</h5>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Primary name</dt>
|
||||
<dd class="col-sm-8">{{ condition.primary }}</dd>
|
||||
|
||||
RCR condition map:
|
||||
{% for map in condition.rcr_curriculum_map.all %}
|
||||
<a href="{{map.get_absolute_url}}">{{map}}</a>
|
||||
{% endfor %}<br />
|
||||
{% endif %}
|
||||
<dt class="col-sm-4">Subspecialty</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for sub in condition.subspecialty.all %}
|
||||
<a class="badge bg-dark text-white me-1 text-decoration-none" href="{{ sub.get_absolute_url }}">{{ sub }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Synonyms</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for syn in condition.synonym.all %}
|
||||
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Parent</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for parent in condition.parent.all %}
|
||||
<a class="badge bg-primary text-white text-decoration-none me-1" href="{{ parent.get_absolute_url }}">{{ parent }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Children</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for child in condition.child.all %}
|
||||
<a class="badge bg-info text-dark text-decoration-none me-1" href="{{ child.get_absolute_url }}">{{ child }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">RCR condition</dt>
|
||||
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
|
||||
|
||||
{% if not condition.rcr_curriculum %}
|
||||
<dt class="col-sm-4">RCR condition map</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for map in condition.rcr_curriculum_map.all %}
|
||||
<a class="badge bg-success text-white text-decoration-none me-1" href="{{ map.get_absolute_url }}">{{ map }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{% if condition.case_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Associated Cases</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for case in condition.case_set.all %}
|
||||
<li class="mb-1">{{ case.get_link }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if condition.differential_set.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Associated Case Differentials</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for diff in condition.differential_set.all %}
|
||||
<li class="mb-1">{{ diff.case.get_link }} <small class="text-muted">[{{ diff.text }}]</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if condition.sbas_questions.exists %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Related SBA Questions</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for q in condition.sbas_questions.all %}
|
||||
<li class="mb-1"><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not condition.rcr_curriculum and can_merge %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Merge</h5>
|
||||
<p class="small text-muted">Merge this condition into another. This will transfer associated cases and differentials but will not transfer synonyms, parents or children.</p>
|
||||
|
||||
<p>
|
||||
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#mergeCollapse" role="button" aria-expanded="false" aria-controls="mergeCollapse">Show merge form</a>
|
||||
</p>
|
||||
|
||||
<div class="collapse" id="mergeCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST" id="condition-merge-form">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
<button class="btn btn-danger"
|
||||
hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||
hx-target="#merge-options" hx-swap="innerHTML"
|
||||
hx-confirm="This will merge {{condition.name}} into the selected condition, this cannot be undone. Continue?">
|
||||
Merge
|
||||
</button>
|
||||
</form>
|
||||
<div id="merge-options" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<aside class="col-lg-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Quick facts</h6>
|
||||
<p class="mb-0"><strong>Case count:</strong> {{ condition.case_set.count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{% if condition.case_set.all %}
|
||||
<h4>Associated Cases</h4>
|
||||
<ul>
|
||||
|
||||
{% for case in condition.case_set.all %}
|
||||
<li>{{case.get_link}}</li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if condition.differential_set.all %}
|
||||
<h4>Associated Case Differentials</h4>
|
||||
<ul>
|
||||
|
||||
{% for diff in condition.differential_set.all %}
|
||||
<li>{{diff.case.get_link}} [{{diff.text}}]</li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if condition.sbas_questions.all %}
|
||||
<h4>Related SBA Questions</h4>
|
||||
<ul>
|
||||
{% for q in condition.sbas_questions.all %}
|
||||
<li><a href="{{ q.get_absolute_url }}">{% if q.title %}{{ q.title }}{% else %}{{ q.get_stem_stripped|truncatechars:140 }}{% endif %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if not condition.rcr_curriculum and can_merge %}
|
||||
|
||||
<details><summary>Merge</summary>
|
||||
Merge this condition into another. This will transfer all associated cases (and differential) but will not transfer synonymns / parents or children.
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
<table>
|
||||
{{ form.as_table }}
|
||||
</table>
|
||||
<button hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||
hx-target="#merge-options" hx-swap="innerHTML"
|
||||
hx-confirm="This will merge {{condition.name}} into the selecetd condition, this cannot be undone. Continue?"
|
||||
>
|
||||
Merge
|
||||
</button>
|
||||
</form>
|
||||
<div id="merge-options"></div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
<div class="floating-header">
|
||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
||||
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
||||
<a href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
|
||||
<a href="{% url 'atlas:case_displaysets' pk=case.pk %}" title="View and edit case display sets">Display Sets</a>
|
||||
<a href="{% url 'atlas:case_clone' pk=case.pk %}"
|
||||
title="Clone the Case (duplicate everything but the images)">Clone</a>
|
||||
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='atlas' pk=case.pk %}')"> Add
|
||||
Note</a>
|
||||
<a href="{% url 'atlas:case_authors' case.pk %}" title="Edit Authors">Authors</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a href="{% url 'admin:atlas_case_change' case.id %}" title="Edit the Case using the admin interface">Admin Edit</a>
|
||||
{% endif %}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start mb-3">
|
||||
<div class="btn-toolbar" role="toolbar" aria-label="Case actions">
|
||||
<div class="btn-group me-2 mb-2" role="group" aria-label="Primary actions">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_displaysets' pk=case.pk %}" title="View and edit case display sets">Display sets</a>
|
||||
</div>
|
||||
|
||||
<div class="btn-group me-2 mb-2" role="group" aria-label="Secondary actions">
|
||||
<a class="btn btn-sm btn-outline-success" href="{% url 'atlas:case_clone' pk=case.pk %}" title="Clone the Case (duplicate everything but the images)">Clone</a>
|
||||
<a class="btn btn-sm btn-outline-danger" href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
||||
<a class="btn btn-sm btn-outline-info" href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='atlas' pk=case.pk %}');">Add Note</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_authors' case.pk %}" title="Edit Authors">Authors</a>
|
||||
{% if request.user.is_superuser %}
|
||||
<a class="btn btn-sm btn-outline-dark" href="{% url 'admin:atlas_case_change' case.id %}" title="Edit the Case using the admin interface">Admin</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if collection %}
|
||||
<div style="float: right;">
|
||||
Collection:Case details
|
||||
{% include "atlas/collection_detail.html#casedetails-management-links" %}
|
||||
</div>
|
||||
<div>
|
||||
{% if previous %}
|
||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous question</a>
|
||||
{% endif %}
|
||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
||||
{% if next %}
|
||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next question</a>
|
||||
{% endif %}
|
||||
<div class="text-end mb-2">
|
||||
<div class="small text-muted">Collection</div>
|
||||
<div class="mb-1">
|
||||
{% include "atlas/collection_detail.html#casedetails-management-links" %}
|
||||
</div>
|
||||
<div class="small">
|
||||
{% if previous %}
|
||||
<a class="me-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous</a>
|
||||
{% endif %}
|
||||
Viewing question as part of a collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{ collection.name }}</a>
|
||||
[{{ case_number|add:1 }} / {{ collection_length }}]
|
||||
{% if next %}
|
||||
<a class="ms-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -347,6 +347,7 @@ urlpatterns = [
|
||||
path("question_schema/schemas", views.question_schema_schemas, name="question_schema_schemas"),
|
||||
path("condition/", views.ConditionView.as_view(), name="condition_view"),
|
||||
path("categories/", views.categories_list, name="categories_list"),
|
||||
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
|
||||
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
|
||||
path(
|
||||
"condition/<int:pk>/delete",
|
||||
|
||||
+50
-9
@@ -1999,16 +1999,57 @@ class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView):
|
||||
@login_required
|
||||
# @user_is_atlas_editor
|
||||
def categories_list(request):
|
||||
# condition = get_object_or_404(Condition, pk=pk)
|
||||
# Allow an optional global search across category models via GET `q`.
|
||||
query = request.GET.get("q", "").strip()
|
||||
# Optional `types` GET parameter (can be repeated) to restrict which category
|
||||
# models to search. Expected values: 'conditions','findings','structures',
|
||||
# 'subspecialties','presentations'. If omitted, search all.
|
||||
types = request.GET.getlist("types")
|
||||
|
||||
# logging.debug(atlas.subspecialty.first().name.all())
|
||||
return render(
|
||||
request,
|
||||
"atlas/categories_list.html",
|
||||
# {
|
||||
# "condition": condition,
|
||||
# },
|
||||
)
|
||||
results = None
|
||||
if query:
|
||||
from .models import Condition, Finding, Structure, Subspecialty, Presentation
|
||||
|
||||
# Helper to conditionally populate each result list only when the
|
||||
# corresponding type is requested (or when no types filter provided).
|
||||
def include(name: str) -> bool:
|
||||
return not types or name in types
|
||||
|
||||
results = {
|
||||
"conditions": Condition.objects.filter(name__icontains=query).order_by("name") if include("conditions") else (),
|
||||
"findings": Finding.objects.filter(name__icontains=query).order_by("name") if include("findings") else (),
|
||||
"structures": Structure.objects.filter(name__icontains=query).order_by("name") if include("structures") else (),
|
||||
"subspecialties": Subspecialty.objects.filter(name__icontains=query).order_by("name") if include("subspecialties") else (),
|
||||
"presentations": Presentation.objects.filter(name__icontains=query).order_by("name") if include("presentations") else (),
|
||||
}
|
||||
|
||||
return render(request, "atlas/categories_list.html", {"query": query, "results": results})
|
||||
|
||||
|
||||
@login_required
|
||||
def categories_search_partial(request):
|
||||
"""Return only the search results partial for HTMX requests.
|
||||
|
||||
Expects GET param `q`.
|
||||
"""
|
||||
query = request.GET.get("q", "").strip()
|
||||
types = request.GET.getlist("types")
|
||||
results = {"conditions": (), "findings": (), "structures": (), "subspecialties": (), "presentations": ()}
|
||||
if query:
|
||||
from .models import Condition, Finding, Structure, Subspecialty, Presentation
|
||||
|
||||
def include(name: str) -> bool:
|
||||
return not types or name in types
|
||||
|
||||
results = {
|
||||
"conditions": Condition.objects.filter(name__icontains=query).order_by("name") if include("conditions") else (),
|
||||
"findings": Finding.objects.filter(name__icontains=query).order_by("name") if include("findings") else (),
|
||||
"structures": Structure.objects.filter(name__icontains=query).order_by("name") if include("structures") else (),
|
||||
"subspecialties": Subspecialty.objects.filter(name__icontains=query).order_by("name") if include("subspecialties") else (),
|
||||
"presentations": Presentation.objects.filter(name__icontains=query).order_by("name") if include("presentations") else (),
|
||||
}
|
||||
|
||||
return render(request, "atlas/_categories_search_results.html", {"query": query, "results": results})
|
||||
|
||||
|
||||
# def collection_view(request):
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 21:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generic', '0026_questionreview'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='questionreview',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('AC', 'Accepted'), ('OD', 'Outdated'), ('ER', 'Error'), ('RJ', 'Rejected'), ('IP', 'In Progress')], default='AC', max_length=2),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,80 @@ from generic.models import CidUser, Examination, Supervisor, findMiddle
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# Generic selection-enabled table base class.
|
||||
# Inherit from this class to automatically get a selection checkbox
|
||||
# column (input name="selection") and table attrs that client-side
|
||||
# scripts can use to find selectable tables.
|
||||
class SelectionTable(tables.Table):
|
||||
# Provide a default selection checkbox column. Subclasses that wish to
|
||||
# customise can override this attribute.
|
||||
selection = tables.CheckBoxColumn(
|
||||
accessor="pk",
|
||||
orderable=False,
|
||||
attrs={
|
||||
'th': {'class': 'selection-col'},
|
||||
'td': {'class': 'selection-col'},
|
||||
'input': {'name': 'selection'},
|
||||
},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta:
|
||||
# Defaults: add js-row-selectable class so client-side code can
|
||||
# locate tables that support row selection.
|
||||
attrs = {"class": "table js-row-selectable", "data-selection-name": "selection"}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Ensure selection column is always placed last in the column order
|
||||
# so that table actions/controls appear to the right. We attempt to
|
||||
# reorder columns after initialization; if django-tables2 provides
|
||||
# `reorder_columns`, use it, otherwise fall back to setting
|
||||
# `self.sequence`.
|
||||
super().__init__(*args, **kwargs)
|
||||
try:
|
||||
cols = list(self.columns.names())
|
||||
if 'selection' in cols:
|
||||
cols.remove('selection')
|
||||
cols.append('selection')
|
||||
try:
|
||||
# Preferred API when available
|
||||
self.reorder_columns(cols)
|
||||
except Exception:
|
||||
# Fallback: set sequence attribute
|
||||
self.sequence = tuple(cols)
|
||||
except Exception:
|
||||
# Defensive: don't break table rendering if introspection fails
|
||||
pass
|
||||
|
||||
@property
|
||||
def row_selection_controls(self):
|
||||
"""Return HTML for the row-selection control bar.
|
||||
|
||||
Usage: render in template with `{{ table.row_selection_controls|safe }}`
|
||||
|
||||
The control IDs are suffixed with a small per-instance token to
|
||||
avoid collisions when multiple tables are present on the same page.
|
||||
"""
|
||||
token = format(id(self), 'x')
|
||||
|
||||
html = (
|
||||
"<div class=\"d-flex justify-content-between align-items-center mb-2\">"
|
||||
" <div>"
|
||||
" <button class=\"btn btn-outline-secondary btn-sm\" id=\"toggle-row-selection-{}\" type=\"button\">Enable row selection</button>"
|
||||
" <div id=\"selection-controls-{}\" class=\"btn-group ms-2\" role=\"group\" style=\"display:none;\">"
|
||||
" <button class=\"btn btn-sm btn-outline-primary\" id=\"select-all-{}\">Select all</button>"
|
||||
" <button class=\"btn btn-sm btn-outline-secondary\" id=\"clear-selection-{}\">Clear</button>"
|
||||
" </div>"
|
||||
" </div>"
|
||||
" <div>"
|
||||
" <small class=\"text-muted\">Selected: <span id=\"selected-count-{}\">0</span></small>"
|
||||
" </div>"
|
||||
"</div>"
|
||||
).format(token, token, token, token, token)
|
||||
|
||||
# Also include a small data attribute so client scripts can locate the
|
||||
# controls by selection name if they prefer.
|
||||
return format_html(html)
|
||||
|
||||
class SingleImageColumn(tables.Column):
|
||||
def render(self, value):
|
||||
|
||||
@@ -1397,19 +1397,6 @@ span#user-id {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Highlight row on hover */
|
||||
.row-selector tbody tr:hover td {
|
||||
background-color: darkblue;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Highlight selected row */
|
||||
.row-selector tbody tr.selected td {
|
||||
background-color: #b3d7ff !important;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.image-block {
|
||||
min-width: 100px;
|
||||
display: inline-block;
|
||||
|
||||
@@ -150,6 +150,7 @@ class QuestionForm(ModelForm):
|
||||
"presentation",
|
||||
"subspecialty",
|
||||
"open_access",
|
||||
"frcr_appropriate",
|
||||
]
|
||||
|
||||
widgets = {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 21:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('sbas', '0021_alter_question_open_access'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='frcr_appropriate',
|
||||
field=models.BooleanField(default=True, help_text='If set, this question is considered appropriate for FRCR exam practice.'),
|
||||
),
|
||||
]
|
||||
@@ -112,6 +112,10 @@ class Question(QuestionBase):
|
||||
default=True, help_text="If set, this question is available to all users for use in their exams."
|
||||
)
|
||||
|
||||
frcr_appropriate = models.BooleanField(
|
||||
default=True, help_text="If set, this question is considered appropriate for FRCR exam practice."
|
||||
)
|
||||
|
||||
#notes = GenericRelation(QuestionNote)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
+8
-6
@@ -6,16 +6,18 @@ from .models import Question, UserAnswer
|
||||
|
||||
from django.utils.html import format_html
|
||||
|
||||
from generic.tables import SelectionTable
|
||||
|
||||
class QuestionTable(tables.Table):
|
||||
|
||||
class QuestionTable(SelectionTable):
|
||||
def get_view_cell(record):
|
||||
if record.open_access:
|
||||
return format_html(f"""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
return format_html("""<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-opencollective" viewBox="0 0 16 16">
|
||||
<path fill-opacity=".4" d="M12.995 8.195c0 .937-.312 1.912-.78 2.693l1.99 1.99c.976-1.327 1.6-2.966 1.6-4.683 0-1.795-.624-3.434-1.561-4.76l-2.068 2.028c.468.781.78 1.679.78 2.732h.04Z"/>
|
||||
<path d="M8 13.151a4.995 4.995 0 1 1 0-9.99c1.015 0 1.951.273 2.732.82l1.95-2.03a7.805 7.805 0 1 0 .04 12.449l-1.951-2.03a5.072 5.072 0 0 1-2.732.781z"/>
|
||||
</svg> View""")
|
||||
else:
|
||||
return f'View'
|
||||
return 'View'
|
||||
#edit = tables.LinkColumn(
|
||||
# "sbas:sbas_question_update", text="Edit", args=[A("pk")], orderable=False
|
||||
#)
|
||||
@@ -27,7 +29,7 @@ class QuestionTable(tables.Table):
|
||||
#)
|
||||
#exams = tables.ManyToManyColumn(verbose_name="Exams")
|
||||
|
||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
#selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||
|
||||
stem = tables.Column()
|
||||
answers = tables.TemplateColumn("<ol type='A'><li>{{ record.a_answer|safe}}</li><li>{{ record.b_answer|safe}}</li><li>{{ record.c_answer|safe}}</li><li>{{ record.d_answer|safe}}</li><li>{{ record.e_answer|safe}}</li></ol>")
|
||||
@@ -36,7 +38,7 @@ class QuestionTable(tables.Table):
|
||||
"sbas:question_update", text="Edit", args=[A("pk")], orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
class Meta(SelectionTable.Meta):
|
||||
model = Question
|
||||
template_name = "django_tables2/bootstrap4.html"
|
||||
fields = (
|
||||
@@ -53,7 +55,7 @@ class QuestionTable(tables.Table):
|
||||
"edit"
|
||||
)
|
||||
sequence = ("view", "stem", "answers")#, "exams")
|
||||
attrs = {"class": "table row-selector"}
|
||||
#`attrs = {"class": "table"}
|
||||
order_by = "-created_date"
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<button
|
||||
class="btn btn-sm {% if question.frcr_appropriate %}btn-success{% else %}btn-outline-secondary{% endif %}"
|
||||
hx-post="{% url 'sbas:toggle_frcr' pk=question.pk %}"
|
||||
hx-swap="outerHTML"
|
||||
title="Toggle FRCR-appropriate"
|
||||
>
|
||||
{% if question.frcr_appropriate %}
|
||||
<i class="bi bi-check-circle me-1" aria-hidden="true"></i>FRCR
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle me-1" aria-hidden="true"></i>FRCR
|
||||
{% endif %}
|
||||
</button>
|
||||
@@ -4,14 +4,18 @@
|
||||
Sbas
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block navigation %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'sbas:index' %}">SBAs</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sbasNavbarNav" aria-controls="sbasNavbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse">
|
||||
<div class="collapse navbar-collapse" id="sbasNavbarNav">
|
||||
<ul class="navbar-nav">
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
@@ -25,6 +29,8 @@
|
||||
<li><a class="dropdown-item" href="{% url 'sbas:question_view' %}"><i class="bi bi-list-ul"></i> All Questions</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'sbas:question_review_start' %}"><i class="bi bi-bookmarks-fill"></i> Review Questions</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'sbas:generate_exam' %}"><i class="bi bi-journal-plus"></i> Generate Exam</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'sbas:question_overview' %}"><i class="bi bi-journal-plus"></i> Questions Overview</a></li>
|
||||
|
||||
<!-- Additional items can be added here -->
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -26,8 +26,9 @@
|
||||
<div class="alert alert-primary review-mode-alert" role="alert">Exam completed.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8 order-1 order-md-1 mb-3">
|
||||
<div class="row flex-wrap">
|
||||
{# Layout change: make both columns full-width so the right panel is not a sidebar #}
|
||||
<div class="col-12 order-1 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="question-stem" class="mb-3">{{ question.stem|safe }}</div>
|
||||
@@ -74,10 +75,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-4 col-md-4 order-2 order-md-2 mb-3">
|
||||
<div class="col-12 order-2 mb-3">
|
||||
{% include "sbas/exam_take_help.html" %}
|
||||
|
||||
<div class="card sticky-top mt-3" style="top:1rem;">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Questions</h6>
|
||||
<p class="small text-muted mb-2">Tap a number to jump to that question.</p>
|
||||
@@ -90,7 +91,7 @@
|
||||
</div>
|
||||
|
||||
<div id="menu-list" class="collapse d-md-block">
|
||||
<div class="d-flex flex-wrap gap-2" id="menu-buttons" role="toolbar" aria-label="Question navigation">
|
||||
<div class="d-flex flex-wrap gap-1" id="menu-buttons" role="toolbar" aria-label="Question navigation">
|
||||
<!-- Buttons injected by JS -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +171,7 @@
|
||||
.question-menu-item {
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
padding: .25rem .5rem;
|
||||
padding: .1rem .1rem;
|
||||
}
|
||||
|
||||
/* Disable sticky behaviour on narrow viewports so the sidebar stacks naturally */
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
</p>
|
||||
|
||||
<h3>Question Generation Prompt</h3>
|
||||
<pre>
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="btn-group me-2" role="group" aria-label="Prompt actions">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="copy-llm-prompt">Copy prompt</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="download-llm-prompt">Download prompt</button>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-llm-question-prompt" aria-expanded="false" aria-controls="collapse-llm-question-prompt">Show</button>
|
||||
</div>
|
||||
<small class="text-muted" id="llm-prompt-feedback" style="display:none;">Copied ✓</small>
|
||||
</div>
|
||||
<div class="collapse" id="collapse-llm-question-prompt">
|
||||
<pre id="llm-question-prompt">
|
||||
You are an expert radiologist tasked with generating high-quality multiple-choice questions for medical imaging education. Each question should consist of a clinical vignette, an image description, and five answer choices (A through E), with one correct answer and four plausible distractors.
|
||||
|
||||
Please adhere to the following guidelines when creating each question:
|
||||
@@ -25,6 +34,7 @@ Please adhere to the following guidelines when creating each question:
|
||||
12. References should be included inline within the feedback fields.
|
||||
13. Sources can also be included in the sources field as a list.
|
||||
14. Questions need to have a radiology focus, please avoid questions that are purely clinical or biochemical without imaging relevance.
|
||||
15. Category names should match one of the following options exactly: 'Central Nervous and Head & Neck', 'Paediatric', 'Genito-urinary, Adrenal, Obstetrics & Gynaecology and Breast', 'Gastro-intestinal', 'Musculoskeletal and Trauma', 'Cardiothoracic and Vascular'
|
||||
|
||||
JSON Schema (draft-07 compatible)
|
||||
{
|
||||
@@ -138,5 +148,134 @@ JSON Schema (draft-07 compatible)
|
||||
}
|
||||
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<h3>Question generation text</h3>
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<div class="btn-group me-2" role="group" aria-label="Prompt actions">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="copy-llm-prompt-text">Copy prompt</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="download-llm-prompt-text">Download prompt</button>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-llm-question-prompt-text" aria-expanded="false" aria-controls="collapse-llm-question-prompt-text">Show</button>
|
||||
</div>
|
||||
<small class="text-muted" id="llm-prompt-text-feedback" style="display:none;">Copied ✓</small>
|
||||
</div>
|
||||
|
||||
<div class="collapse" id="collapse-llm-question-prompt-text">
|
||||
<pre id="llm-question-prompt-text">
|
||||
Paying particular attention to the content in the source "prompt" please generate 10 questions
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const copyBtn = document.getElementById('copy-llm-prompt');
|
||||
const downloadBtn = document.getElementById('download-llm-prompt');
|
||||
const pre = document.getElementById('llm-question-prompt');
|
||||
const feedback = document.getElementById('llm-prompt-feedback');
|
||||
|
||||
function showFeedback(msg) {
|
||||
if (!feedback) return;
|
||||
feedback.textContent = msg;
|
||||
feedback.style.display = 'inline';
|
||||
setTimeout(() => feedback.style.display = 'none', 2000);
|
||||
}
|
||||
|
||||
if (copyBtn && pre) {
|
||||
copyBtn.addEventListener('click', function() {
|
||||
const text = pre.textContent;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showFeedback('Copied ✓');
|
||||
}).catch(function() {
|
||||
// Fallback to older execCommand
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); showFeedback('Copied ✓'); } catch (e) { showFeedback('Copy failed'); }
|
||||
ta.remove();
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); showFeedback('Copied ✓'); } catch (e) { showFeedback('Copy failed'); }
|
||||
ta.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadBtn && pre) {
|
||||
downloadBtn.addEventListener('click', function() {
|
||||
const text = pre.textContent;
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'llm_prompt.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
showFeedback('Downloaded');
|
||||
|
||||
|
||||
// Handlers for the second prompt (llm-question-prompt-text)
|
||||
const copyBtnText = document.getElementById('copy-llm-prompt-text');
|
||||
const downloadBtnText = document.getElementById('download-llm-prompt-text');
|
||||
const preText = document.getElementById('llm-question-prompt-text');
|
||||
const feedbackText = document.getElementById('llm-prompt-text-feedback');
|
||||
|
||||
function showTextFeedback(msg) {
|
||||
if (!feedbackText) return;
|
||||
feedbackText.textContent = msg;
|
||||
feedbackText.style.display = 'inline';
|
||||
setTimeout(() => feedbackText.style.display = 'none', 2000);
|
||||
}
|
||||
|
||||
if (copyBtnText && preText) {
|
||||
copyBtnText.addEventListener('click', function() {
|
||||
const text = preText.textContent;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showTextFeedback('Copied ✓');
|
||||
}).catch(function() {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); showTextFeedback('Copied ✓'); } catch (e) { showTextFeedback('Copy failed'); }
|
||||
ta.remove();
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); showTextFeedback('Copied ✓'); } catch (e) { showTextFeedback('Copy failed'); }
|
||||
ta.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadBtnText && preText) {
|
||||
downloadBtnText.addEventListener('click', function() {
|
||||
const text = preText.textContent;
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'llm_prompt_text.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
showTextFeedback('Downloaded');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -209,6 +209,11 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2"><strong>FRCR appropriate:</strong>
|
||||
<div>
|
||||
{% include 'sbas/_frcr_toggle.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="py-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5">Question overview</h2>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'sbas:question_view' %}">All questions</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="row g-2 mb-3">
|
||||
<div class="col-auto">
|
||||
<label class="form-label small">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
{% for v, label in status_options %}
|
||||
<option value="{{ v }}" {% if selected_status == v %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Category and other filters are rendered by the django-filter form inside Advanced filters. #}
|
||||
|
||||
<div class="col-auto">
|
||||
<label class="form-label small">Open access</label>
|
||||
<select name="open_access" class="form-select form-select-sm">
|
||||
<option value="">(Any)</option>
|
||||
<option value="1" {% if selected_open_access == '1' %}selected{% endif %}>Yes</option>
|
||||
<option value="0" {% if selected_open_access == '0' %}selected{% endif %}>No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="w-100"></div>
|
||||
|
||||
<div class="col-12">
|
||||
<p>
|
||||
<a class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" href="#advanced-filters" role="button" aria-expanded="false" aria-controls="advanced-filters">Advanced filters</a>
|
||||
</p>
|
||||
<div class="collapse" id="advanced-filters">
|
||||
<div class="card card-body">
|
||||
{% if qfilter %}
|
||||
<div class="mb-3">
|
||||
<h6 class="small">Full filter form</h6>
|
||||
{{ qfilter.form.media }}
|
||||
{{ qfilter.form.as_p }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto align-self-end">
|
||||
<button class="btn btn-sm btn-primary">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Counts by category <small class="text-muted">({{ total }} questions)</small></h6>
|
||||
|
||||
{% if counts %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for row in counts %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div><a href="{{ row.link }}">{{ row.cat_name }}</a></div>
|
||||
<div class="text-muted">{{ row.count }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="small text-muted">No matching questions.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}{% endblock %}
|
||||
@@ -153,7 +153,13 @@
|
||||
const params = new URLSearchParams();
|
||||
if (activeCategory) params.set('category', activeCategory);
|
||||
if (activeStatus) params.set('status', activeStatus);
|
||||
// Next navigates to the next matching item (no skip)
|
||||
// Preserve any existing skip value from current URL so the caller's
|
||||
// skip position isn't lost when moving to the next item.
|
||||
const curParams = new URLSearchParams(window.location.search);
|
||||
const curSkip = curParams.get('skip');
|
||||
if (curSkip) params.set('skip', curSkip);
|
||||
|
||||
// Next navigates to the next matching item (respecting skip)
|
||||
const qs = params.toString();
|
||||
window.location.href = qs ? (base + '?' + qs) : base;
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="view-filter-options">
|
||||
|
||||
@@ -110,6 +110,12 @@ urlpatterns = [
|
||||
views.generate_exam,
|
||||
name="generate_exam",
|
||||
),
|
||||
path(
|
||||
"question/overview/",
|
||||
views.question_overview,
|
||||
name="question_overview",
|
||||
),
|
||||
path("question/<int:pk>/toggle_frcr/", views.toggle_frcr_appropriate, name="toggle_frcr"),
|
||||
path("category/merge/", views.merge_category, name="merge_category"),
|
||||
#path(
|
||||
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
|
||||
|
||||
+174
-1
@@ -24,7 +24,7 @@ from django.core.cache import cache
|
||||
|
||||
from django.urls import reverse_lazy, reverse
|
||||
|
||||
from django.http import Http404, HttpResponseBadRequest, JsonResponse
|
||||
from django.http import Http404, HttpResponseBadRequest, JsonResponse, HttpResponseForbidden
|
||||
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
|
||||
@@ -511,6 +511,179 @@ def generate_exam(request):
|
||||
return render(request, "sbas/generate_exam.html", {"categories": categories, "status_options": status_options})
|
||||
|
||||
|
||||
@login_required
|
||||
def question_overview(request):
|
||||
"""Show counts of questions grouped by category (default) with simple filters.
|
||||
|
||||
Default behaviour:
|
||||
- group_by=category
|
||||
- status=AC (Accepted)
|
||||
|
||||
Available GET params:
|
||||
- status: AC, OD, ER, RJ, IP, ANY, UNREVIEWED
|
||||
- category: category id to filter (optional)
|
||||
- open_access: '1' or '0' to filter
|
||||
"""
|
||||
# Prepare status options as elsewhere
|
||||
status_options = [("ANY", "Any"), ("UNREVIEWED", "Unreviewed")]
|
||||
for v, label in QuestionReview.StatusChoices.choices:
|
||||
status_options.append((v, label))
|
||||
|
||||
status = request.GET.get("status") or QuestionReview.StatusChoices.ACCEPTED
|
||||
group_by = request.GET.get("group_by") or "category"
|
||||
|
||||
# Use django-filter to build the base filtered queryset. QuestionFilter
|
||||
# already applies permission filters when given the request.
|
||||
from .filters import QuestionFilter
|
||||
|
||||
qfilter = QuestionFilter(data=request.GET or None, queryset=Question.objects.all().order_by("pk"), request=request)
|
||||
qs = qfilter.qs
|
||||
|
||||
# We'll still support a top-level status filter (review status) via a GET param
|
||||
# and advanced atlas filters are provided by the QuestionFilter form.
|
||||
# Preserve selected values for building links (use getlist for multi-selects)
|
||||
category_filters = request.GET.getlist("category")
|
||||
finding_filters = request.GET.getlist("finding")
|
||||
structure_filters = request.GET.getlist("structure")
|
||||
condition_filters = request.GET.getlist("condition")
|
||||
subspecialty_filters = request.GET.getlist("subspecialty")
|
||||
presentation_filters = request.GET.getlist("presentation")
|
||||
open_access = request.GET.get("open_access")
|
||||
|
||||
# Optimize status filtering with a DB subquery to get latest review status per question
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import OuterRef, Subquery, Count
|
||||
|
||||
ct = ContentType.objects.get_for_model(Question)
|
||||
latest_status_subq = QuestionReview.objects.filter(content_type=ct, object_id=OuterRef("pk")).order_by("-created_on").values("status")[:1]
|
||||
qs = qs.annotate(latest_review_status=Subquery(latest_status_subq))
|
||||
|
||||
if status == "ANY":
|
||||
pass
|
||||
elif status == "UNREVIEWED":
|
||||
qs = qs.filter(latest_review_status__isnull=True)
|
||||
else:
|
||||
qs = qs.filter(latest_review_status=status)
|
||||
|
||||
# Respect authors_only: non-owners should be excluded
|
||||
# We applied permission filter earlier; ensure authors_only exclusion
|
||||
from django.db.models import Q
|
||||
try:
|
||||
if not request.user.is_superuser:
|
||||
qs = qs.exclude(Q(authors_only=True) & ~Q(author__in=[request.user]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Aggregate counts grouped by category (DB-side)
|
||||
grouped = qs.values("category__id", "category__category").annotate(count=Count("pk")).order_by("-count", "category__category")
|
||||
|
||||
counts_rows = []
|
||||
total = qs.count()
|
||||
|
||||
# Build per-row link (to question list) preserving current filters but swapping category
|
||||
from urllib.parse import urlencode
|
||||
from django.urls import reverse
|
||||
|
||||
# Convert selected filters to strings for template membership tests
|
||||
selected_category = [str(x) for x in category_filters]
|
||||
selected_findings = [str(x) for x in finding_filters]
|
||||
selected_structures = [str(x) for x in structure_filters]
|
||||
selected_conditions = [str(x) for x in condition_filters]
|
||||
selected_subspecialties = [str(x) for x in subspecialty_filters]
|
||||
selected_presentations = [str(x) for x in presentation_filters]
|
||||
|
||||
for row in grouped:
|
||||
cat_id = row.get("category__id")
|
||||
cat_name = row.get("category__category") or "Uncategorized"
|
||||
count = row.get("count")
|
||||
|
||||
params = []
|
||||
# category param points to this row
|
||||
if cat_id:
|
||||
params.append(("category", str(cat_id)))
|
||||
# preserve other filters
|
||||
if status:
|
||||
params.append(("status", status))
|
||||
if open_access is not None and open_access != "":
|
||||
params.append(("open_access", open_access))
|
||||
for fid in selected_findings:
|
||||
params.append(("finding", fid))
|
||||
for sid in selected_structures:
|
||||
params.append(("structure", sid))
|
||||
for cid in selected_conditions:
|
||||
params.append(("condition", cid))
|
||||
for ss in selected_subspecialties:
|
||||
params.append(("subspecialty", ss))
|
||||
for p in selected_presentations:
|
||||
params.append(("presentation", p))
|
||||
|
||||
link = reverse("sbas:question_view") + ("?" + urlencode(params, doseq=True) if params else "")
|
||||
|
||||
counts_rows.append({"cat_id": cat_id or "", "cat_name": cat_name, "count": count, "link": link})
|
||||
|
||||
# Populate atlas filter lists
|
||||
from atlas.models import Finding, Structure, Condition as AtlasCondition, Subspecialty, Presentation as AtlasPresentation
|
||||
|
||||
categories = Category.objects.all().order_by("category")
|
||||
findings = Finding.objects.all().order_by("name")
|
||||
structures = Structure.objects.all().order_by("name")
|
||||
conditions = AtlasCondition.objects.all().order_by("name")
|
||||
subspecialties = Subspecialty.objects.all().order_by("name")
|
||||
presentations = AtlasPresentation.objects.all().order_by("name")
|
||||
|
||||
context = {
|
||||
"status_options": status_options,
|
||||
"selected_status": status,
|
||||
"group_by": group_by,
|
||||
"counts": counts_rows,
|
||||
"total": total,
|
||||
"categories": categories,
|
||||
"selected_category": selected_category,
|
||||
"selected_open_access": open_access,
|
||||
"findings": findings,
|
||||
"structures": structures,
|
||||
"conditions": conditions,
|
||||
"subspecialties": subspecialties,
|
||||
"presentations": presentations,
|
||||
"selected_findings": selected_findings,
|
||||
"selected_structures": selected_structures,
|
||||
"selected_conditions": selected_conditions,
|
||||
"selected_subspecialties": selected_subspecialties,
|
||||
"selected_presentations": selected_presentations,
|
||||
"qfilter": qfilter,
|
||||
}
|
||||
|
||||
return render(request, "sbas/question_overview.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST", "GET"])
|
||||
def toggle_frcr_appropriate(request, pk):
|
||||
"""Toggle or set the Question.frcr_appropriate boolean via HTMX.
|
||||
|
||||
Accepts POST requests. If POST contains 'frcr' parameter its truthiness
|
||||
will be applied; otherwise the value is toggled.
|
||||
|
||||
Returns the rendered partial `_frcr_toggle.html` so HTMX can swap it in.
|
||||
"""
|
||||
question = get_object_or_404(Question, pk=pk)
|
||||
|
||||
# Simple permission: allow staff or users with change permission
|
||||
if not (request.user.is_staff or request.user.has_perm('sbas.change_question')):
|
||||
return HttpResponseForbidden("Forbidden")
|
||||
|
||||
# Toggle or set
|
||||
if request.method == "POST":
|
||||
val = request.POST.get("frcr")
|
||||
if val is None:
|
||||
question.frcr_appropriate = not question.frcr_appropriate
|
||||
else:
|
||||
question.frcr_appropriate = str(val).lower() in ("1", "true", "yes", "on")
|
||||
question.save()
|
||||
|
||||
return render(request, "sbas/_frcr_toggle.html", {"question": question})
|
||||
|
||||
|
||||
class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||
model = UserAnswer
|
||||
table_class = UserAnswerTable
|
||||
|
||||
@@ -110,6 +110,77 @@
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* Improve navbar tap targets and mobile dropdown behaviour */
|
||||
@media (max-width: 991.98px) {
|
||||
/* Make navbar links larger and easier to tap */
|
||||
.navbar-nav .nav-link {
|
||||
padding: .75rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
min-height: 44px; /* recommended minimum touch target */
|
||||
display: block;
|
||||
margin: .25rem 0;
|
||||
border-radius: .375rem;
|
||||
background-color: var(--bs-primary);
|
||||
color: #fff !important;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.05) inset;
|
||||
}
|
||||
|
||||
/* Make links look like outline buttons for secondary links */
|
||||
.navbar-nav .nav-link.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--bs-primary) !important;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
/* Make dropdown items full-width and larger */
|
||||
.navbar-nav .dropdown-menu .dropdown-item {
|
||||
padding: .75rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: .25rem;
|
||||
margin: .125rem 0;
|
||||
}
|
||||
|
||||
/* When the navbar is collapsed, render dropdown menus as static stacked blocks
|
||||
so nested items are easy to tap (prevents absolute-position overlap issues). */
|
||||
.navbar-collapse .dropdown-menu {
|
||||
position: static !important;
|
||||
float: none !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
/* Ensure dropdown toggles are full-width when collapsed */
|
||||
.navbar-collapse .dropdown > .nav-link {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
html, body {
|
||||
/* Slightly increase root font-size so rem-based components scale */
|
||||
font-size: 182%;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Slightly larger headings for clear hierarchy */
|
||||
h1 { font-size: 1.8rem; }
|
||||
h2 { font-size: 1.4rem; }
|
||||
h3 { font-size: 1.15rem; }
|
||||
|
||||
/* Improve spacing for paragraphs and lists */
|
||||
p, li { font-size: 1rem; }
|
||||
|
||||
/* Make form controls and buttons a bit larger for touch */
|
||||
.form-control, .btn { font-size: 1rem; padding: .6rem .75rem; }
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
@@ -237,7 +308,178 @@
|
||||
console.error('Error hiding review modal', err);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Wire up all row-selection control blocks to the nearest .js-row-selectable table.
|
||||
function findTableForControl(elem) {
|
||||
// Walk up ancestors and look for a table that either has the
|
||||
// `js-row-selectable` class or contains a checkbox named 'selection'.
|
||||
//var ancestor = elem.closest('div, section, main, article, form, body');
|
||||
//while (ancestor) {
|
||||
// // Prefer explicit selectable class
|
||||
// var tbl = ancestor.querySelector && ancestor.querySelector('table.js-row-selectable');
|
||||
// if (tbl) return tbl;
|
||||
// // Otherwise look for any table in this ancestor that contains a
|
||||
// // checkbox input named 'selection' (or any checkbox as a fallback).
|
||||
// var tables = ancestor.querySelectorAll && ancestor.querySelectorAll('table');
|
||||
// if (tables && tables.length) {
|
||||
// for (var i = 0; i < tables.length; i++) {
|
||||
// var t = tables[i];
|
||||
// if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) return t;
|
||||
// }
|
||||
// }
|
||||
// ancestor = ancestor.parentElement;
|
||||
//}
|
||||
// fallback to any table on the page that looks like it has selection
|
||||
var any = document.querySelector('table.js-row-selectable') //|| Array.from(document.querySelectorAll('table')).find(function(t){ return t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]')); });
|
||||
return any || null;
|
||||
}
|
||||
|
||||
// Helper to build id with optional token
|
||||
function suffixed(idBase, token) {
|
||||
return token ? idBase + '-' + token : idBase;
|
||||
}
|
||||
|
||||
// Ensure every selectable table has a row-selection control block. If the
|
||||
// page author didn't render the controls, create them automatically and
|
||||
// insert them before the table so the behavior is available out-of-the-box.
|
||||
function ensureControlsForTable(table) {
|
||||
if (!table) return;
|
||||
// detect an existing nearby toggle button
|
||||
var existing = table.previousElementSibling && table.previousElementSibling.querySelector && table.previousElementSibling.querySelector('[id^="toggle-row-selection"]');
|
||||
if (existing) return; // already present
|
||||
|
||||
// generate a short token for unique IDs
|
||||
var token = (Date.now().toString(16) + Math.floor(Math.random()*0xffff).toString(16));
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'row-selection-controls-wrapper';
|
||||
wrapper.innerHTML = '<div class="d-flex justify-content-between align-items-center mb-2">'
|
||||
+ ' <div>'
|
||||
+ ' <button class="btn btn-outline-secondary btn-sm" id="toggle-row-selection-' + token + '" type="button">Enable row selection</button>'
|
||||
+ ' <div id="selection-controls-' + token + '" class="btn-group ms-2" role="group" style="display:none;">'
|
||||
+ ' <button class="btn btn-sm btn-outline-primary" id="select-all-' + token + '">Select all</button>'
|
||||
+ ' <button class="btn btn-sm btn-outline-secondary" id="clear-selection-' + token + '">Clear</button>'
|
||||
+ ' </div>'
|
||||
+ ' </div>'
|
||||
+ ' <div>'
|
||||
+ ' <small class="text-muted">Selected: <span id="selected-count-' + token + '">0</span></small>'
|
||||
+ ' </div>'
|
||||
+ '</div>';
|
||||
|
||||
// Insert the controls immediately before the table (or its responsive wrapper)
|
||||
var container = table.closest('.table-responsive') || table;
|
||||
container.parentNode.insertBefore(wrapper, container);
|
||||
}
|
||||
|
||||
// Create controls for any table that looks selectable but lacks a control block
|
||||
Array.from(document.querySelectorAll('table')).forEach(function(t){
|
||||
if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) {
|
||||
ensureControlsForTable(t);
|
||||
}
|
||||
});
|
||||
|
||||
// For every toggle button (supports both unsuffixed and suffixed IDs)
|
||||
document.querySelectorAll('button[id^="toggle-row-selection"]').forEach(function(toggleBtn) {
|
||||
// derive token (empty for unsuffixed)
|
||||
var token = '';
|
||||
if (toggleBtn.id !== 'toggle-row-selection') token = toggleBtn.id.replace('toggle-row-selection-', '');
|
||||
|
||||
var controlsEl = document.getElementById(suffixed('selection-controls', token));
|
||||
var selectAllBtn = document.getElementById(suffixed('select-all', token));
|
||||
var clearBtn = document.getElementById(suffixed('clear-selection', token));
|
||||
var selectedCountSpan = document.getElementById(suffixed('selected-count', token));
|
||||
|
||||
var table = findTableForControl(toggleBtn);
|
||||
if (!table) return; // nothing to do
|
||||
|
||||
var selectionEnabled = false;
|
||||
|
||||
function findRowCheckboxes() {
|
||||
if (!table) return [];
|
||||
// prefer named selection inputs but fall back to any checkbox
|
||||
var checks = Array.from(table.querySelectorAll('input[type="checkbox"][name="selection"]'));
|
||||
if (!checks.length) checks = Array.from(table.querySelectorAll('input[type="checkbox"]'));
|
||||
return checks;
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
if (!selectedCountSpan) return;
|
||||
var checks = findRowCheckboxes();
|
||||
var count = checks.filter(function(c){ return c.checked; }).length;
|
||||
selectedCountSpan.textContent = count;
|
||||
checks.forEach(function(cb){ var tr = cb.closest('tr'); if (tr) tr.classList.toggle('table-active', cb.checked); });
|
||||
}
|
||||
|
||||
function setCheckboxesDisabled(disabled) {
|
||||
findRowCheckboxes().forEach(function(cb){ cb.disabled = disabled; });
|
||||
}
|
||||
|
||||
function hideSelectionColumn() {
|
||||
if (!table) return;
|
||||
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = 'none'; });
|
||||
}
|
||||
|
||||
function showSelectionColumn() {
|
||||
if (!table) return;
|
||||
table.querySelectorAll('th, td').forEach(function(cell){ if (cell.querySelector && cell.querySelector('input[type="checkbox"][name="selection"]')) cell.style.display = ''; });
|
||||
}
|
||||
|
||||
// initialise state
|
||||
setCheckboxesDisabled(true);
|
||||
hideSelectionColumn();
|
||||
|
||||
// row click toggling (attach to table to avoid global handlers)
|
||||
table.addEventListener('click', function(e){
|
||||
if (!selectionEnabled) return;
|
||||
var tag = (e.target.tagName || '').toLowerCase();
|
||||
if (['input','a','button','select','textarea','label'].indexOf(tag) !== -1) return;
|
||||
var tr = e.target.closest('tr');
|
||||
if (!tr || !table.contains(tr)) return;
|
||||
var cb = tr.querySelector('input[type="checkbox"][name="selection"]') || tr.querySelector('input[type="checkbox"]');
|
||||
if (cb && !cb.disabled) { cb.checked = !cb.checked; tr.classList.toggle('table-active', cb.checked); updateSelectedCount(); }
|
||||
}, true);
|
||||
|
||||
// listen for changes within the table to keep counts in sync
|
||||
table.addEventListener('change', function(e){ if (e.target && e.target.matches && e.target.matches('input[type="checkbox"]')) updateSelectedCount(); }, true);
|
||||
|
||||
// wire controls
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
selectionEnabled = !selectionEnabled;
|
||||
if (selectionEnabled) {
|
||||
toggleBtn.textContent = 'Disable row selection';
|
||||
if (controlsEl) controlsEl.style.display = '';
|
||||
setCheckboxesDisabled(false);
|
||||
showSelectionColumn();
|
||||
} else {
|
||||
toggleBtn.textContent = 'Enable row selection';
|
||||
if (controlsEl) controlsEl.style.display = 'none';
|
||||
setCheckboxesDisabled(true);
|
||||
hideSelectionColumn();
|
||||
findRowCheckboxes().forEach(function(cb){ cb.checked = false; });
|
||||
updateSelectedCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = true; }); updateSelectedCount(); });
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', function(){ findRowCheckboxes().forEach(function(cb){ if (!cb.disabled) cb.checked = false; }); updateSelectedCount(); });
|
||||
}
|
||||
|
||||
// initial count
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
|
||||
</html>
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
<div>
|
||||
Supervisor: {{ user.userprofile.supervisor }}
|
||||
{% if request.user.is_superuser %}
|
||||
{% if request.user.is_superuser and user.userprofile.supervisor %}
|
||||
<a href='{% url "generic:supervisor_detail" user.userprofile.supervisor.pk %}'><i class="bi bi-link"></i></a>
|
||||
{% endif %}
|
||||
<br/>
|
||||
|
||||
@@ -204,3 +204,6 @@
|
||||
.filter-card label { color: #e6eef6; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user