Compare commits

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