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');
|
window.loadDicomViewerEvent = new Event('loadDicomViewer');
|
||||||
|
|||||||
+22
-41
@@ -1,7 +1,7 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_tables2.utils import A
|
from django_tables2.utils import A
|
||||||
|
|
||||||
from generic.tables import SeriesImageColumn
|
from generic.tables import SeriesImageColumn, SelectionTable
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Case,
|
Case,
|
||||||
@@ -25,7 +25,7 @@ from easy_thumbnails.exceptions import InvalidImageFormatError
|
|||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
|
|
||||||
|
|
||||||
class CaseTable(tables.Table):
|
class CaseTable(SelectionTable):
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
# Optimize prefetching for casecollection_set
|
# Optimize prefetching for casecollection_set
|
||||||
#case_collections_prefetch = Prefetch(
|
#case_collections_prefetch = Prefetch(
|
||||||
@@ -174,9 +174,8 @@ class CaseTable(tables.Table):
|
|||||||
"atlas:case_delete", text="Delete", args=[A("pk")], orderable=False
|
"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
|
model = Case
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = (
|
fields = (
|
||||||
@@ -190,7 +189,6 @@ class CaseTable(tables.Table):
|
|||||||
)
|
)
|
||||||
sequence = ("view", )
|
sequence = ("view", )
|
||||||
order_by = "-created_date"
|
order_by = "-created_date"
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -205,7 +203,7 @@ class PopupLinkColumn(tables.Column):
|
|||||||
return obj.get_thumbnail()[0]
|
return obj.get_thumbnail()[0]
|
||||||
|
|
||||||
|
|
||||||
class SeriesTable(tables.Table):
|
class SeriesTable(SelectionTable):
|
||||||
edit = tables.LinkColumn(
|
edit = tables.LinkColumn(
|
||||||
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
"atlas:series_update", text="Edit", args=[A("pk")], orderable=False
|
||||||
)
|
)
|
||||||
@@ -219,13 +217,12 @@ class SeriesTable(tables.Table):
|
|||||||
delete = tables.LinkColumn(
|
delete = tables.LinkColumn(
|
||||||
"atlas:series_delete", text="Delete", args=[A("pk")], orderable=False
|
"atlas:series_delete", text="Delete", args=[A("pk")], orderable=False
|
||||||
)
|
)
|
||||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
|
||||||
|
|
||||||
case = tables.ManyToManyColumn(verbose_name="Cases")
|
case = tables.ManyToManyColumn(verbose_name="Cases")
|
||||||
findings = tables.ManyToManyColumn(verbose_name="Findings")
|
findings = tables.ManyToManyColumn(verbose_name="Findings")
|
||||||
structures = tables.ManyToManyColumn(verbose_name="Structures")
|
structures = tables.ManyToManyColumn(verbose_name="Structures")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Series
|
model = Series
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = (
|
fields = (
|
||||||
@@ -240,7 +237,6 @@ class SeriesTable(tables.Table):
|
|||||||
)
|
)
|
||||||
sequence = ("view", "popup", "images", "case")
|
sequence = ("view", "popup", "images", "case")
|
||||||
order_by = "-created_date"
|
order_by = "-created_date"
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
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(
|
name = tables.Column(
|
||||||
linkify=("atlas:condition_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:condition_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="Condition",
|
verbose_name="Condition",
|
||||||
@@ -271,18 +267,16 @@ class ConditionTable(tables.Table):
|
|||||||
delete = tables.LinkColumn(
|
delete = tables.LinkColumn(
|
||||||
"atlas:condition_delete", text="Delete", args=[A("pk")], orderable=False
|
"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.ManyToManyColumn(verbose_name="Synonyms")
|
||||||
synonym = tables.Column(empty_values=())
|
synonym = tables.Column(empty_values=())
|
||||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Condition
|
model = Condition
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
fields = ("primary", "subspecialty", "rcr_curriculum_map", "rcr_curriculum")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
@@ -299,7 +293,7 @@ class ConditionTable(tables.Table):
|
|||||||
return f"{record.get_synonym_link()}"
|
return f"{record.get_synonym_link()}"
|
||||||
|
|
||||||
|
|
||||||
class FindingTable(tables.Table):
|
class FindingTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:finding_detail", {"pk": tables.A("pk")}), verbose_name="Finding"
|
linkify=("atlas:finding_detail", {"pk": tables.A("pk")}), verbose_name="Finding"
|
||||||
)
|
)
|
||||||
@@ -309,25 +303,23 @@ class FindingTable(tables.Table):
|
|||||||
delete = tables.LinkColumn(
|
delete = tables.LinkColumn(
|
||||||
"atlas:finding_delete", text="Delete", args=[A("pk")], orderable=False
|
"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.ManyToManyColumn(verbose_name="Synonyms")
|
||||||
synonym = tables.Column(empty_values=())
|
synonym = tables.Column(empty_values=())
|
||||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Finding
|
model = Finding
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name", "primary")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
return format_html(record.get_synonym_link())
|
return format_html(record.get_synonym_link())
|
||||||
|
|
||||||
|
|
||||||
class StructureTable(tables.Table):
|
class StructureTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:structure_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:structure_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="Structure",
|
verbose_name="Structure",
|
||||||
@@ -338,25 +330,23 @@ class StructureTable(tables.Table):
|
|||||||
delete = tables.LinkColumn(
|
delete = tables.LinkColumn(
|
||||||
"atlas:structure_delete", text="Delete", args=[A("pk")], orderable=False
|
"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.ManyToManyColumn(verbose_name="Synonyms")
|
||||||
synonym = tables.Column(empty_values=())
|
synonym = tables.Column(empty_values=())
|
||||||
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
parent = tables.ManyToManyColumn(verbose_name="Parents")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Structure
|
model = Structure
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "primary")
|
fields = ("name", "primary")
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
return format_html(record.get_synonym_link())
|
return format_html(record.get_synonym_link())
|
||||||
|
|
||||||
|
|
||||||
class PresentationTable(tables.Table):
|
class PresentationTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:presentation_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:presentation_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="Presentation",
|
verbose_name="Presentation",
|
||||||
@@ -368,20 +358,19 @@ class PresentationTable(tables.Table):
|
|||||||
# delete = tables.LinkColumn(
|
# delete = tables.LinkColumn(
|
||||||
# "atlas:presentation_delete", text="Delete", args=[A("pk")], orderable=False
|
# "atlas:presentation_delete", text="Delete", args=[A("pk")], orderable=False
|
||||||
# )
|
# )
|
||||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
|
||||||
|
|
||||||
subspecialty = tables.ManyToManyColumn(verbose_name="Subspecialty")
|
subspecialty = tables.ManyToManyColumn(verbose_name="Subspecialty")
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Presentation
|
model = Presentation
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name",)
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PathologicalProcessTable(tables.Table):
|
|
||||||
|
class PathologicalProcessTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:pathological_process_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:pathological_process_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="PathologicalProcess",
|
verbose_name="PathologicalProcess",
|
||||||
@@ -393,18 +382,15 @@ class PathologicalProcessTable(tables.Table):
|
|||||||
# delete = tables.LinkColumn(
|
# delete = tables.LinkColumn(
|
||||||
# "atlas:pathological_process_delete", text="Delete", args=[A("pk")], orderable=False
|
# "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
|
model = PathologicalProcess
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name",)
|
fields = ("name",)
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
|
class SubspecialtyTable(SelectionTable):
|
||||||
class SubspecialtyTable(tables.Table):
|
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:subspecialty_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="Subspecialty",
|
verbose_name="Subspecialty",
|
||||||
@@ -416,21 +402,19 @@ class SubspecialtyTable(tables.Table):
|
|||||||
# delete = tables.LinkColumn(
|
# delete = tables.LinkColumn(
|
||||||
# "atlas:subspecialty_delete", text="Delete", args=[A("pk")], orderable=False
|
# "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
|
model = Subspecialty
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ()
|
fields = ()
|
||||||
sequence = ("name",)
|
sequence = ("name",)
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
def render_synonym(self, value, record):
|
def render_synonym(self, value, record):
|
||||||
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 CaseCollectionTable(tables.Table):
|
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
|
||||||
)
|
)
|
||||||
@@ -446,9 +430,8 @@ class CaseCollectionTable(tables.Table):
|
|||||||
|
|
||||||
#differential = tables.ManyToManyColumn(verbose_name="Differential")
|
#differential = tables.ManyToManyColumn(verbose_name="Differential")
|
||||||
|
|
||||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = CaseCollection
|
model = CaseCollection
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = (
|
fields = (
|
||||||
@@ -459,7 +442,6 @@ class CaseCollectionTable(tables.Table):
|
|||||||
"author",
|
"author",
|
||||||
)
|
)
|
||||||
sequence = ("view", )#, "series")
|
sequence = ("view", )#, "series")
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
@@ -471,7 +453,7 @@ class CaseCollectionTable(tables.Table):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
class QuestionSchemaTable(tables.Table):
|
class QuestionSchemaTable(SelectionTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
linkify=("atlas:question_schema_detail", {"pk": tables.A("pk")}),
|
||||||
verbose_name="QuestionSchema",
|
verbose_name="QuestionSchema",
|
||||||
@@ -484,11 +466,10 @@ class QuestionSchemaTable(tables.Table):
|
|||||||
"atlas:question_schema_delete", text="Delete", args=[A("pk")], orderable=False
|
"atlas:question_schema_delete", text="Delete", args=[A("pk")], orderable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = QuestionSchema
|
model = QuestionSchema
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = ("name", "description", "schema")
|
fields = ("name", "description", "schema")
|
||||||
attrs = {"class": "table row-selector"}
|
|
||||||
|
|
||||||
|
|
||||||
def render_schema(self, value, record):
|
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">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item dropdown">
|
<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">
|
<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>
|
<span class="visually-hidden">Toggle Dropdown</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li>
|
<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>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<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>
|
||||||
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<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">
|
<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>
|
<span class="visually-hidden">Toggle Dropdown</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li>
|
<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>
|
||||||
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<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">
|
<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>
|
<span class="visually-hidden">Toggle Dropdown</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li>
|
<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>
|
||||||
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<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>
|
||||||
<li class="nav-item">
|
<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>
|
||||||
<li class="nav-item">
|
<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>
|
||||||
<li class="nav-item">
|
<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>
|
||||||
<li class="nav-item">
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,15 +108,46 @@
|
|||||||
|
|
||||||
|
|
||||||
{% else %}
|
{% 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 %}
|
{% 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">
|
<div class="float-end text-end">
|
||||||
{{ case.created_date|date:"d/m/Y" }}
|
{# 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>
|
||||||
<div class="id">
|
|
||||||
ID: {{ case.id }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details id="dicom-viewer-details">
|
<details id="dicom-viewer-details">
|
||||||
@@ -140,11 +171,32 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="pre-whitespace"><b>History:</b> {{ case.history |linebreaks}}</p>
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-12 col-md-12">
|
||||||
<p class="pre-whitespace"><b>Discussion:</b> {{ case.discussion |linebreaks}}</p>
|
<div class="card h-100">
|
||||||
|
<div class="card-header">History</div>
|
||||||
<p class="pre-whitespace"><b>Report:</b> {{ case.report |linebreaks}}</p>
|
<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 %}
|
<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>
|
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a>
|
||||||
{% if caseresource.pre_review %}
|
{% if caseresource.pre_review %}
|
||||||
@@ -157,7 +209,9 @@
|
|||||||
|
|
||||||
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
<div class="pre-whitespace multi-image-block"><b>Series:</b>
|
||||||
<form>
|
<form>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-start" id="case-series-row">
|
||||||
{% partial case-series %}
|
{% partial case-series %}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<span>
|
<span>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -227,7 +281,7 @@
|
|||||||
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
|
href="{% url 'atlas:case_scrap' pk=case.pk %}">(toggle)</a> {% endcomment %}
|
||||||
|
|
||||||
<div class="atlas-answer">
|
<div class="atlas-answer">
|
||||||
<p class="pre-whitespace">
|
<div class="pre-whitespace">
|
||||||
Condition:
|
Condition:
|
||||||
<ul>
|
<ul>
|
||||||
{% for con in case.condition.all %}
|
{% for con in case.condition.all %}
|
||||||
@@ -373,21 +427,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
Subspecialty:
|
</div>
|
||||||
<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>
|
<b>Collections:</b>
|
||||||
|
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
@@ -728,6 +770,14 @@
|
|||||||
background-color: blue;
|
background-color: blue;
|
||||||
transition: background-color 0.3s ease, border 0.3s ease;
|
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 {
|
#series-reorder-list .dragging {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
@@ -740,4 +790,7 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.link-buttons {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -12,6 +12,109 @@
|
|||||||
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
|
<li><a href="{% url 'atlas:pathological_process_view' %}">Pathological Process</li></a>
|
||||||
</ul>
|
</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 %}
|
{% if request.user.is_staff %}
|
||||||
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,96 +1,157 @@
|
|||||||
{% extends 'atlas/base.html' %}
|
{% extends 'atlas/base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<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">
|
||||||
<div class="floating-header">
|
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:condition_update' pk=condition.pk %}">Edit</a>
|
||||||
<a href="{% url 'atlas:condition_update' pk=condition.pk %}" title="Edit the Condition">Edit</a>
|
<a class="btn btn-sm btn-outline-danger" href="{% url 'atlas:condition_delete' pk=condition.pk %}">Delete</a>
|
||||||
<a href="{% url 'atlas:condition_delete' pk=condition.pk %}" title="Delete the Condition">Delete</a>
|
|
||||||
{% if request.user.is_superuser %}
|
{% if request.user.is_superuser %}
|
||||||
<a href="{% url 'admin:atlas_condition_change' condition.id %}"
|
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:atlas_condition_change' condition.id %}">Admin</a>
|
||||||
title="Edit the Condition using the admin interface">Admin Edit</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<h3>Name: {{condition.name}}</h3>
|
|
||||||
Primary name: {{condition.primary}}<br />
|
<div class="row">
|
||||||
Subspecialty: {% for sub in condition.subspecialty.all %}
|
<div class="col-lg-8">
|
||||||
<a href="{{sub.get_absolute_url}}">{{sub}}</a>
|
<section class="mb-3">
|
||||||
{% endfor %}<br />
|
<h5 class="mb-2">Details</h5>
|
||||||
Synonyms:
|
<dl class="row">
|
||||||
|
<dt class="col-sm-4">Primary name</dt>
|
||||||
|
<dd class="col-sm-8">{{ condition.primary }}</dd>
|
||||||
|
|
||||||
|
<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 %}
|
{% for syn in condition.synonym.all %}
|
||||||
<a href="{{syn.get_absolute_url}}">{{syn}}</a>
|
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
||||||
{% endfor %}<br />
|
{% empty %}
|
||||||
Parent:
|
—
|
||||||
|
{% endfor %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Parent</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
{% for parent in condition.parent.all %}
|
{% for parent in condition.parent.all %}
|
||||||
<a href="{{parent.get_absolute_url}}">{{parent}}</a>
|
<a class="badge bg-primary text-white text-decoration-none me-1" href="{{ parent.get_absolute_url }}">{{ parent }}</a>
|
||||||
{% endfor %}<br />
|
{% empty %}
|
||||||
Children:
|
—
|
||||||
|
{% endfor %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Children</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
{% for child in condition.child.all %}
|
{% for child in condition.child.all %}
|
||||||
<a href="{{child.get_absolute_url}}">{{child}}</a>
|
<a class="badge bg-info text-dark text-decoration-none me-1" href="{{ child.get_absolute_url }}">{{ child }}</a>
|
||||||
{% endfor %}<br />
|
{% empty %}
|
||||||
RCR condition: {{condition.rcr_curriculum}}<br/>
|
—
|
||||||
|
{% 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 %}
|
{% if not condition.rcr_curriculum %}
|
||||||
|
<dt class="col-sm-4">RCR condition map</dt>
|
||||||
RCR condition map:
|
<dd class="col-sm-8">
|
||||||
{% for map in condition.rcr_curriculum_map.all %}
|
{% for map in condition.rcr_curriculum_map.all %}
|
||||||
<a href="{{map.get_absolute_url}}">{{map}}</a>
|
<a class="badge bg-success text-white text-decoration-none me-1" href="{{ map.get_absolute_url }}">{{ map }}</a>
|
||||||
{% endfor %}<br />
|
{% empty %}
|
||||||
|
—
|
||||||
|
{% endfor %}
|
||||||
|
</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
{% if condition.case_set.exists %}
|
||||||
{% if condition.case_set.all %}
|
<div class="card mb-3">
|
||||||
<h4>Associated Cases</h4>
|
<div class="card-body">
|
||||||
<ul>
|
<h5 class="card-title">Associated Cases</h5>
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
{% for case in condition.case_set.all %}
|
{% for case in condition.case_set.all %}
|
||||||
<li>{{case.get_link}}</li>
|
<li class="mb-1">{{ case.get_link }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if condition.differential_set.all %}
|
|
||||||
<h4>Associated Case Differentials</h4>
|
|
||||||
<ul>
|
|
||||||
|
|
||||||
|
{% 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 %}
|
{% for diff in condition.differential_set.all %}
|
||||||
<li>{{diff.case.get_link}} [{{diff.text}}]</li>
|
<li class="mb-1">{{ diff.case.get_link }} <small class="text-muted">[{{ diff.text }}]</small></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if condition.sbas_questions.all %}
|
{% if condition.sbas_questions.exists %}
|
||||||
<h4>Related SBA Questions</h4>
|
<div class="card mb-3">
|
||||||
<ul>
|
<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 %}
|
{% 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>
|
<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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% if not condition.rcr_curriculum and can_merge %}
|
{% 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>
|
||||||
|
|
||||||
<details><summary>Merge</summary>
|
<p>
|
||||||
Merge this condition into another. This will transfer all associated cases (and differential) but will not transfer synonymns / parents or children.
|
<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>
|
||||||
<form method="POST">
|
</p>
|
||||||
|
|
||||||
|
<div class="collapse" id="mergeCollapse">
|
||||||
|
<div class="card card-body">
|
||||||
|
<form method="POST" id="condition-merge-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<table>
|
<div class="mb-3">
|
||||||
{{ form.as_table }}
|
{{ form.as_p }}
|
||||||
</table>
|
</div>
|
||||||
<button hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
<button class="btn btn-danger"
|
||||||
|
hx-post='{% url "atlas:condition_merge" condition.pk %}'
|
||||||
hx-target="#merge-options" hx-swap="innerHTML"
|
hx-target="#merge-options" hx-swap="innerHTML"
|
||||||
hx-confirm="This will merge {{condition.name}} into the selecetd condition, this cannot be undone. Continue?"
|
hx-confirm="This will merge {{condition.name}} into the selected condition, this cannot be undone. Continue?">
|
||||||
>
|
|
||||||
Merge
|
Merge
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="merge-options"></div>
|
<div id="merge-options" class="mt-3"></div>
|
||||||
</details>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,39 @@
|
|||||||
<div class="floating-header">
|
<div class="d-flex flex-wrap justify-content-between align-items-start mb-3">
|
||||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
<div class="btn-toolbar" role="toolbar" aria-label="Case actions">
|
||||||
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
<div class="btn-group me-2 mb-2" role="group" aria-label="Primary actions">
|
||||||
<a href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
|
<a class="btn btn-sm btn-outline-primary" href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
||||||
<a href="{% url 'atlas:case_displaysets' pk=case.pk %}" title="View and edit case display sets">Display Sets</a>
|
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
||||||
<a href="{% url 'atlas:case_clone' pk=case.pk %}"
|
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
|
||||||
title="Clone the Case (duplicate everything but the images)">Clone</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>
|
||||||
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
</div>
|
||||||
<a href="#"
|
|
||||||
onclick="return window.create_popup_window('{% url 'feedback_create' question_type='atlas' pk=case.pk %}')"> Add
|
<div class="btn-group me-2 mb-2" role="group" aria-label="Secondary actions">
|
||||||
Note</a>
|
<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 href="{% url 'atlas:case_authors' case.pk %}" title="Edit Authors">Authors</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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if collection %}
|
{% if collection %}
|
||||||
<div style="float: right;">
|
<div class="text-end mb-2">
|
||||||
Collection:Case details
|
<div class="small text-muted">Collection</div>
|
||||||
|
<div class="mb-1">
|
||||||
{% include "atlas/collection_detail.html#casedetails-management-links" %}
|
{% include "atlas/collection_detail.html#casedetails-management-links" %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="small">
|
||||||
{% if previous %}
|
{% if previous %}
|
||||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous question</a>
|
<a class="me-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:-1 %}">Previous</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
Viewing question as part of collection: <a href="{% url 'atlas:collection_detail' collection.id %}">{{collection.name}}</a> [{{case_number|add:1}}/{{collection_length}}]
|
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 %}
|
{% if next %}
|
||||||
<a href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next question</a>
|
<a class="ms-2" href="{% url 'atlas:collection_case_view' collection.id case_number|add:1 %}">Next</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ urlpatterns = [
|
|||||||
path("question_schema/schemas", views.question_schema_schemas, name="question_schema_schemas"),
|
path("question_schema/schemas", views.question_schema_schemas, name="question_schema_schemas"),
|
||||||
path("condition/", views.ConditionView.as_view(), name="condition_view"),
|
path("condition/", views.ConditionView.as_view(), name="condition_view"),
|
||||||
path("categories/", views.categories_list, name="categories_list"),
|
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>", views.condition_detail, name="condition_detail"),
|
||||||
path(
|
path(
|
||||||
"condition/<int:pk>/delete",
|
"condition/<int:pk>/delete",
|
||||||
|
|||||||
+50
-9
@@ -1999,16 +1999,57 @@ class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView):
|
|||||||
@login_required
|
@login_required
|
||||||
# @user_is_atlas_editor
|
# @user_is_atlas_editor
|
||||||
def categories_list(request):
|
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())
|
results = None
|
||||||
return render(
|
if query:
|
||||||
request,
|
from .models import Condition, Finding, Structure, Subspecialty, Presentation
|
||||||
"atlas/categories_list.html",
|
|
||||||
# {
|
# Helper to conditionally populate each result list only when the
|
||||||
# "condition": condition,
|
# 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):
|
# 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
|
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):
|
class SingleImageColumn(tables.Column):
|
||||||
def render(self, value):
|
def render(self, value):
|
||||||
|
|||||||
@@ -1397,19 +1397,6 @@ span#user-id {
|
|||||||
opacity: 1;
|
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 {
|
.image-block {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ class QuestionForm(ModelForm):
|
|||||||
"presentation",
|
"presentation",
|
||||||
"subspecialty",
|
"subspecialty",
|
||||||
"open_access",
|
"open_access",
|
||||||
|
"frcr_appropriate",
|
||||||
]
|
]
|
||||||
|
|
||||||
widgets = {
|
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."
|
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)
|
#notes = GenericRelation(QuestionNote)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
+8
-6
@@ -6,16 +6,18 @@ from .models import Question, UserAnswer
|
|||||||
|
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
from generic.tables import SelectionTable
|
||||||
|
|
||||||
class QuestionTable(tables.Table):
|
|
||||||
|
class QuestionTable(SelectionTable):
|
||||||
def get_view_cell(record):
|
def get_view_cell(record):
|
||||||
if record.open_access:
|
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 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"/>
|
<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""")
|
</svg> View""")
|
||||||
else:
|
else:
|
||||||
return f'View'
|
return 'View'
|
||||||
#edit = tables.LinkColumn(
|
#edit = tables.LinkColumn(
|
||||||
# "sbas:sbas_question_update", text="Edit", args=[A("pk")], orderable=False
|
# "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")
|
#exams = tables.ManyToManyColumn(verbose_name="Exams")
|
||||||
|
|
||||||
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
#selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
|
||||||
|
|
||||||
stem = tables.Column()
|
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>")
|
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
|
"sbas:question_update", text="Edit", args=[A("pk")], orderable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta(SelectionTable.Meta):
|
||||||
model = Question
|
model = Question
|
||||||
template_name = "django_tables2/bootstrap4.html"
|
template_name = "django_tables2/bootstrap4.html"
|
||||||
fields = (
|
fields = (
|
||||||
@@ -53,7 +55,7 @@ class QuestionTable(tables.Table):
|
|||||||
"edit"
|
"edit"
|
||||||
)
|
)
|
||||||
sequence = ("view", "stem", "answers")#, "exams")
|
sequence = ("view", "stem", "answers")#, "exams")
|
||||||
attrs = {"class": "table row-selector"}
|
#`attrs = {"class": "table"}
|
||||||
order_by = "-created_date"
|
order_by = "-created_date"
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
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
|
Sbas
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block css %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
<nav class="navbar navbar-expand-lg navbar-dark submenu">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="{% url 'sbas:index' %}">SBAs</a>
|
<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>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse">
|
<div class="collapse navbar-collapse" id="sbasNavbarNav">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<li class="nav-item">
|
<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_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: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: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 -->
|
<!-- Additional items can be added here -->
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -26,8 +26,9 @@
|
|||||||
<div class="alert alert-primary review-mode-alert" role="alert">Exam completed.</div>
|
<div class="alert alert-primary review-mode-alert" role="alert">Exam completed.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row flex-wrap">
|
||||||
<div class="col-12 col-md-8 order-1 order-md-1 mb-3">
|
{# 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">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="question-stem" class="mb-3">{{ question.stem|safe }}</div>
|
<div id="question-stem" class="mb-3">{{ question.stem|safe }}</div>
|
||||||
@@ -74,10 +75,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" %}
|
{% 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">
|
<div class="card-body">
|
||||||
<h6 class="card-title">Questions</h6>
|
<h6 class="card-title">Questions</h6>
|
||||||
<p class="small text-muted mb-2">Tap a number to jump to that question.</p>
|
<p class="small text-muted mb-2">Tap a number to jump to that question.</p>
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="menu-list" class="collapse d-md-block">
|
<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 -->
|
<!-- Buttons injected by JS -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +171,7 @@
|
|||||||
.question-menu-item {
|
.question-menu-item {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: .25rem .5rem;
|
padding: .1rem .1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disable sticky behaviour on narrow viewports so the sidebar stacks naturally */
|
/* Disable sticky behaviour on narrow viewports so the sidebar stacks naturally */
|
||||||
|
|||||||
@@ -7,7 +7,16 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Question Generation Prompt</h3>
|
<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.
|
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:
|
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.
|
12. References should be included inline within the feedback fields.
|
||||||
13. Sources can also be included in the sources field as a list.
|
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.
|
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)
|
JSON Schema (draft-07 compatible)
|
||||||
{
|
{
|
||||||
@@ -138,5 +148,134 @@ JSON Schema (draft-07 compatible)
|
|||||||
}
|
}
|
||||||
|
|
||||||
</pre>
|
</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 %}
|
{% endblock %}
|
||||||
@@ -209,6 +209,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-2"><strong>FRCR appropriate:</strong>
|
||||||
|
<div>
|
||||||
|
{% include 'sbas/_frcr_toggle.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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();
|
const params = new URLSearchParams();
|
||||||
if (activeCategory) params.set('category', activeCategory);
|
if (activeCategory) params.set('category', activeCategory);
|
||||||
if (activeStatus) params.set('status', activeStatus);
|
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();
|
const qs = params.toString();
|
||||||
window.location.href = qs ? (base + '?' + qs) : base;
|
window.location.href = qs ? (base + '?' + qs) : base;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block css %}
|
{% block css %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div id="view-filter-options">
|
<div id="view-filter-options">
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ urlpatterns = [
|
|||||||
views.generate_exam,
|
views.generate_exam,
|
||||||
name="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("category/merge/", views.merge_category, name="merge_category"),
|
||||||
#path(
|
#path(
|
||||||
# "exam/<int:pk>/scores/<int:cid>/<str:passcode>/",
|
# "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.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
|
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})
|
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):
|
class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView):
|
||||||
model = UserAnswer
|
model = UserAnswer
|
||||||
table_class = UserAnswerTable
|
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>
|
</style>
|
||||||
{% block css %}
|
{% block css %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -237,7 +308,178 @@
|
|||||||
console.error('Error hiding review modal', err);
|
console.error('Error hiding review modal', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</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>
|
</body>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Supervisor: {{ user.userprofile.supervisor }}
|
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>
|
<a href='{% url "generic:supervisor_detail" user.userprofile.supervisor.pk %}'><i class="bi bi-link"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<br/>
|
<br/>
|
||||||
|
|||||||
@@ -204,3 +204,6 @@
|
|||||||
.filter-card label { color: #e6eef6; }
|
.filter-card label { color: #e6eef6; }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user