Compare commits

..
24 Commits
Author SHA1 Message Date
Ross b424defd57 Optimize exam collection queries by annotating exam counts and prefetching authors to improve template performance 2025-11-07 22:16:24 +00:00
Ross 00a675b715 Refactor exam collections list to use Bootstrap cards for improved layout and add total exam count display 2025-11-07 22:13:09 +00:00
Ross bd0859cce0 Add HTMX indicator for import form to enhance user feedback during submission 2025-11-07 21:53:03 +00:00
Ross 2bf74ea297 Fix event listener closure in LLM prompt view for proper feedback handling 2025-11-07 21:51:55 +00:00
Ross 71e9b47c6b Enhance button labels for clarity in exam collection detail view 2025-11-07 21:27:08 +00:00
Ross 9f8b4ff36a Add candidate count method to ExamOrCollectionGenericBase and update exam list template 2025-11-07 21:10:52 +00:00
Ross 9b8e6f1398 Convert date inputs to ISO format on form submit for correct Django parsing 2025-11-06 22:20:07 +00:00
Ross 0ff60da006 Add date range filtering to exams with jQuery UI datepicker support 2025-11-06 22:18:50 +00:00
Ross 53e18e296e Improve exam display layout by adding badges for questions and candidates in the exam list 2025-11-06 22:09:31 +00:00
Ross 8f2ca66bbb Enhance bulk selection functionality in exam index template with visual cues and row click toggling 2025-11-06 22:03:48 +00:00
Ross ebad9c6bd9 Refactor exam date handling to use start_date instead of date in bulk update functionality 2025-11-06 22:01:57 +00:00
Ross 54afa6e6bd Implement bulk update and delete functionality for exams with HTMX support 2025-11-06 21:53:35 +00:00
Ross 17541b8187 Remove filter details section from exam index template 2025-11-06 21:44:21 +00:00
Ross 04ce90049a Apply date from original collection to cloned exams in ExamCollectionClone 2025-11-06 21:41:53 +00:00
Ross 2d963259ca Add exam collection link to the navigation menu and clean up dropdown toggle 2025-11-06 21:30:14 +00:00
Ross ccb27af697 Add search questions link to the navigation menu 2025-11-05 22:20:43 +00:00
Ross dfdba4cce6 Add question search functionality with HTMX support for dynamic results 2025-11-05 22:19:24 +00:00
Ross b12a45275c Enhance case search results display to include series modalities and examinations 2025-11-05 22:08:54 +00:00
Ross 4f6e9fae9d Enhance case search functionality with loading indicator for improved user experience 2025-11-05 22:03:02 +00:00
Ross 22299b9298 Refactor case search functionality to include additional fields for improved search accuracy 2025-11-05 21:58:43 +00:00
Ross 06df2c4188 Enhance case search functionality to include additional fields and related names for improved search results 2025-11-05 21:54:28 +00:00
Ross 8c1c7b75e7 Refactor case search input and results handling for improved functionality and clarity 2025-11-05 21:52:53 +00:00
Ross 03a24615ab Add case search functionality with HTMX support and results partial 2025-11-05 21:37:57 +00:00
Ross 89a767034c Update image retrieval in Case model to use get_images method for improved clarity 2025-11-05 21:18:15 +00:00
21 changed files with 801 additions and 83 deletions
+1 -1
View File
@@ -591,7 +591,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
logger.debug(f"Building stacks for case {case_obj.pk} with prefix '{prefix}'")
stacks = []
for series in case_obj.get_ordered_series():
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
name = f"{prefix}: {series}" if prefix else str(series)
stacks.append({"name": name, "imageIds": images})
return stacks
+27
View File
@@ -42,6 +42,33 @@
</ul>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Search cases</h5>
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="case-search-input" name="q" type="search"
placeholder="Search cases by title or description"
hx-get="{% url 'atlas:case_search_partial' %}"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#case-search-results"
hx-indicator="#case-search-indicator" />
<span class="input-group-text" id="case-search-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading</span>
</span>
</div>
</div>
<style>
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
#case-search-indicator { display: none; }
#case-search-indicator.htmx-request { display: inline-flex; align-items: center; }
</style>
<div id="case-search-results">
{% include 'atlas/partials/_case_search_results.html' with cases=None %}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,35 @@
{# partial: list of cases for HTMX search results #}
<div class="list-group">
{% if cases %}
{% for case in cases %}
<a href="{% url 'atlas:case_detail' case.pk %}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ case.title }}</h6>
<small class="text-muted">{{ case.created_date|date:"Y-m-d" }}</small>
</div>
<div class="mb-1">
<small class="text-muted me-2">{% if case.author.all %}By {{ case.author.all|join:", " }}{% endif %}</small>
{% if case.condition %}<span class="badge bg-secondary me-1">{{ case.condition.name }}</span>{% endif %}
{% if case.presentation %}<span class="badge bg-secondary me-1">{{ case.presentation.name }}</span>{% endif %}
{% if case.subspecialty %}<span class="badge bg-secondary">{{ case.subspecialty.name }}</span>{% endif %}
</div>
<div class="mb-1">
{# show up to three series modalities/examinations if present #}
{% for s in case.series.all|slice:":3" %}
<span class="badge bg-light text-dark me-1">
{% if s.modality %}{{ s.modality.modality }}{% endif %}
{% if s.examination %} / {{ s.examination.examination }}{% endif %}
</span>
{% empty %}
<small class="text-muted">No series</small>
{% endfor %}
</div>
<p class="mb-1 text-truncate">{{ case.description }}</p>
</a>
{% endfor %}
{% else %}
<div class="list-group-item">No cases found</div>
{% endif %}
</div>
+1
View File
@@ -348,6 +348,7 @@ urlpatterns = [
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("search/cases/", views.case_search_partial, name="case_search_partial"),
path("condition/<int:pk>", views.condition_detail, name="condition_detail"),
path(
"condition/<int:pk>/delete",
+82
View File
@@ -183,6 +183,88 @@ from helpers.cimar import CimarAPI
logger = logging.getLogger(__name__)
@login_required
def case_search_partial(request):
"""Return an HTMX partial listing cases matching the query parameter `q`.
This view returns a small list-group fragment that can be swapped into
the index page. It respects the user's available cases via
get_cases_available_to_user.
"""
# NOTE (search migration):
#
# This view currently performs simple `__icontains` lookups across Case
# fields and related models. For better relevance and performance you can
# migrate this to use PostgreSQL full-text search (FTS). Recommended steps:
#
# 1) Enable optional Postgres extensions (if you want trigram/unaccent):
# - Add migrations: `TrigramExtension()` and optionally `UnaccentExtension()`
# from `django.contrib.postgres.operations`.
#
# 2) Create a tsvector expression index (recommended) or add a
# SearchVectorField on the Case model. Example expression index SQL:
# CREATE INDEX CONCURRENTLY idx_case_search ON atlas_case
# USING GIN (to_tsvector('english', coalesce(title,'') || ' ' ||
# coalesce(description,'') || ' ' || coalesce(history,'')));
#
# Alternatively use a Django migration with `GinIndex` on an
# `SearchVectorField` or a `Func` expression.
#
# 3) Query using django.contrib.postgres.search utilities. Example:
# from django.contrib.postgres.search import (
# SearchVector, SearchQuery, SearchRank
# )
# sv = (SearchVector('title', weight='A') +
# SearchVector('description', weight='B') +
# SearchVector('history', weight='C'))
# sq = SearchQuery(q)
# qs = Case.objects.annotate(search=sv).filter(search=sq)
# .annotate(rank=SearchRank(F('search'), sq)).order_by('-rank')
#
# 4) For interactive/fuzzy typing consider pg_trgm (TrigramExtension)
# and `TrigramSimilarity` as a fallback when FTS returns few results.
#
# 5) Keep in mind to re-index (or recreate indexes) when you change
# the fields included in the tsvector. Use concurrent index creation
# for minimal downtime.
#
# If you want, I can prepare the migration, model/index changes and a
# Django-code example to wire FTS into this view and tune weights.
q = request.GET.get('q', '') or request.GET.get('case-search-input', '')
q = q.strip()
# Start with cases available to the user
qs = get_cases_available_to_user(request.user)
if q:
from django.db.models import Q
# Search across multiple Case fields and related names (conditions,
# presentations, subspecialties, series fields and authors).
qs = qs.filter(
Q(title__icontains=q)
| Q(description__icontains=q)
| Q(history__icontains=q)
| Q(discussion__icontains=q)
| Q(report__icontains=q)
| Q(condition__name__icontains=q)
| Q(presentation__name__icontains=q)
| Q(subspecialty__name__icontains=q)
| Q(series__description__icontains=q)
# Series.modality is a FK to generic.Modality; search its text field
| Q(series__modality__modality__icontains=q)
| Q(series__examination__examination__icontains=q)
| Q(author__first_name__icontains=q)
| Q(author__last_name__icontains=q)
| Q(author__username__icontains=q)
).distinct()
qs = qs.order_by('-created_date')[:20]
html = render_to_string('atlas/partials/_case_search_results.html', {'cases': qs}, request=request)
return HttpResponse(html)
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
+3
View File
@@ -1002,6 +1002,9 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
def add_marker(self, user):
self.markers.add(user)
def get_candidate_count(self):
return self.valid_cid_users.count() + self.valid_user_users.count()
class ExamBase(ExamOrCollectionGenericBase):
exam_mode = models.BooleanField(
+108 -27
View File
@@ -1,35 +1,49 @@
{% extends app_name|add:'/base.html' %}
{% block content %}
<details class="filter">
<summary>
Filter exam list
</summary>
<form method="get">
{{ filter.form.as_p }}
<input type="submit" />
</form>
</details>
<h4 class="exam-number-title">{{filter.qs|length}} exams found.</h4>
{% for exam in filter.qs %}
<div class="">
<h1><a href="{% url app_name|add:':exam_overview' pk=exam.pk %}">
{% if exam.exam_mode %}
Exam:
{% else %}
Packet:
{% endif %}
{{ exam }} </a></h1>
<span class="authors">Authors: {{exam.get_authors}}</span>
{% if exam.exam_mode %}
{% if app_name in "rapids longs anatomy" %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}">Mark answers</a>{% endif %}
{% endif %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}">Scores</a>{% endif %}
{% endif %}
<div id="exam-list-wrapper">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-toggle-btn">Bulk edit</button>
</div>
<div class="d-flex align-items-center">
<div id="bulk-controls" class="d-none">
<div>
<button type="button" class="btn btn-sm btn-outline-primary" id="select-all-btn">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-selection-btn">Clear</button>
</div>
<form id="bulk-date-form" class="mt-2" hx-post="{% url app_name|add:':exam_bulk_update' %}" hx-target="#exam-list-container" hx-include="#exam-list-container input[name='exam_ids']" hx-indicator="#bulk-action-indicator">
{% for k, v in request.GET.items %}
<input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endfor %}
{% csrf_token %}
<div class="input-group input-group-sm">
<input type="date" class="form-control" name="date" aria-label="Set date">
<button class="btn btn-sm btn-primary" type="submit">Update date</button>
</div>
</form>
<form id="bulk-delete-form" class="mt-2" hx-post="{% url app_name|add:':exam_bulk_delete' %}" hx-target="#exam-list-container" hx-include="#exam-list-container input[name='exam_ids']" hx-indicator="#bulk-action-indicator">
{% for k, v in request.GET.items %}
<input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endfor %}
{% csrf_token %}
<button class="btn btn-sm btn-danger" type="submit" onclick="return confirm('Delete selected exams? This action is irreversible.')">Delete selected</button>
</form>
<!-- Bulk action indicator shown while HTMX requests are running -->
<span id="bulk-action-indicator" class="ms-2" aria-hidden="true">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
</span>
</div>
</div>
</div>
{% include 'generic/partials/_exam_list.html' with filter=filter app_name=app_name %}
</div>
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
{% endblock %}
@@ -40,7 +54,74 @@
float: right;
opacity: 50%;}
/* Hide selection checkboxes unless bulk-mode is enabled on the wrapper */
#exam-list-wrapper input.exam-select-checkbox { display: none; }
#exam-list-wrapper.bulk-mode input.exam-select-checkbox { display: inline-block; }
/* Show bulk controls area only when not d-none (toggled by JS) */
/* Visual affordances when bulk-mode is active */
#exam-list-wrapper.bulk-mode .mb-2 { cursor: pointer; }
#exam-list-wrapper .mb-2.selected { background-color: rgba(0,123,255,0.06); }
/* Defensive: hide bulk-controls unless bulk-mode is enabled */
#exam-list-wrapper:not(.bulk-mode) #bulk-controls { display: none !important; }
/* Bulk action indicator - hidden by default, shown while HTMX request is active */
#bulk-action-indicator { display: none; }
#bulk-action-indicator.htmx-request { display: inline-block; }
</style>
{% endblock css %}
{% block js %}
<script>
document.addEventListener('DOMContentLoaded', function(){
const bulkToggleBtn = document.getElementById('bulk-toggle-btn');
const bulkControls = document.getElementById('bulk-controls');
const examListWrapper = document.getElementById('exam-list-wrapper');
function setAllCheckboxes(checked){
document.querySelectorAll('#exam-list-container input.exam-select-checkbox').forEach(cb => cb.checked = checked);
}
// Attach select/clear handlers delegated (elements may be hidden initially)
document.addEventListener('click', function(e){
if(e.target && e.target.id === 'select-all-btn'){
setAllCheckboxes(true);
}
if(e.target && e.target.id === 'clear-selection-btn'){
setAllCheckboxes(false);
}
});
if(bulkToggleBtn){
bulkToggleBtn.addEventListener('click', function(){
const enabled = examListWrapper.classList.toggle('bulk-mode');
if(enabled){
bulkControls.classList.remove('d-none');
} else {
bulkControls.classList.add('d-none');
setAllCheckboxes(false);
}
});
}
// Make clicking an exam row toggle its checkbox when bulk-mode is enabled.
document.addEventListener('click', function(e){
if(!examListWrapper.classList.contains('bulk-mode')) return;
// find the closest exam container (id like exam-<pk>)
const examItem = e.target.closest('[id^="exam-"]');
if(!examItem || !examListWrapper.contains(examItem)) return;
// don't toggle when clicking interactive controls
if(e.target.closest('a, button, input, label, form')) return;
const cb = examItem.querySelector('input.exam-select-checkbox');
if(cb){
cb.checked = !cb.checked;
examItem.classList.toggle('selected', cb.checked);
}
});
});
</script>
{% endblock %}
@@ -32,7 +32,7 @@
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'anatomy' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Anatomy Exams</button>
</div>
{% endif %}
@@ -43,7 +43,7 @@
{% endwith %}
<a href="{% url 'longs:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'longs' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Longs Exams</button>
</div>
{% endif %}
@@ -54,7 +54,7 @@
{% endwith %}
<a href="{% url 'rapids:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'rapids' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Rapids Exams</button>
</div>
{% endif %}
@@ -65,7 +65,7 @@
{% endwith %}
<a href="{% url 'shorts:exam_list_collection' object.pk %}">View exam list</a>
<div class="add-marker">
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker</button>
<button class="btn-sm add-marker-button" hx-get="{% url 'generic:exam_collection_add_marker' object.pk 'shorts' %}" hx-target=".add-marker" hx-swap="innerHTML">Add marker to Shorts Exams</button>
</div>
{% endif %}
@@ -87,7 +87,7 @@
<br/>
<div id="add-user">
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author</button>
<button hx-get="{% url 'generic:exam_collection_add_author' object.pk %}" hx-target="#add-user" hx-swap="innerHTML">Add author to all exams</button>
</div>
{% include 'exam_overview_js.html' %}
@@ -2,15 +2,58 @@
{% block content %}
<h1>Exam Collections</h1>
<ul class="collections">
{% for collection in object_list %}
<li class="collection"><a href="{% url 'generic:examcollection_detail' collection.pk %}">{{ collection.name }} [{{ collection.date}}] (Authors: {{ collection.get_authors }})</a></li>
{% empty %}
<li>No exam collections yet.</li>
{% endfor %}
</ul>
<h1 class="mb-4">Exam Collections</h1>
{% if object_list %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% for collection in object_list %}
<div class="col">
<div class="card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<div class="mb-2">
<a class="stretched-link h5 mb-0 d-block" href="{% url 'generic:examcollection_detail' collection.pk %}">{{ collection.name }}</a>
{% if collection.date %}
<div class="text-muted small">Date: {{ collection.date }}</div>
{% endif %}
</div>
<div class="mb-2">
<small class="text-muted">Authors: {{ collection.get_authors }}</small>
</div>
<div class="mb-3">
{% comment %} Show archive state and counts per exam type {% endcomment %}
{% if collection.archive %}
<span class="badge bg-secondary" title="Archived">Archived</span>
{% endif %}
{# Use annotated counts provided by the view to avoid per-card COUNT queries #}
<span class="badge bg-info text-dark ms-1" title="Total exams">{{ collection.total_count }} exams</span>
{# Per-type badges (only show non-zero) #}
{% with a=collection.anatomy_count l=collection.longs_count p=collection.physics_count r=collection.rapids_count s=collection.sbas_count %}
{% if a %}<span class="badge bg-primary ms-1">Anatomy {{ a }}</span>{% endif %}
{% if l %}<span class="badge bg-primary ms-1">Longs {{ l }}</span>{% endif %}
{% if p %}<span class="badge bg-primary ms-1">Physics {{ p }}</span>{% endif %}
{% if r %}<span class="badge bg-primary ms-1">Rapids {{ r }}</span>{% endif %}
{% if s %}<span class="badge bg-primary ms-1">SBAs {{ s }}</span>{% endif %}
{% endwith %}
</div>
<div class="mt-auto d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="{% url 'generic:examcollection_detail' collection.pk %}">View</a>
{% if can_edit %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:examcollection_edit' collection.pk %}">Edit</a>
<a class="btn btn-sm btn-outline-warning" href="{% url 'generic:examcollection_clone' collection.pk %}">Clone</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">No exam collections yet.</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,32 @@
{# Partial: list of exams used by HTMX bulk actions #}
<div id="exam-list-container">
<h4 class="exam-number-title">{{ filter.qs|length }} exams found.</h4>
{% for exam in filter.qs %}
<div class="mb-2" id="exam-{{ exam.pk }}">
<div class="d-flex align-items-start">
<div class="form-check me-2">
<input class="form-check-input exam-select-checkbox" type="checkbox" name="exam_ids" value="{{ exam.pk }}" id="select-exam-{{ exam.pk }}">
</div>
<div>
<h5 class="mb-0 d-flex align-items-center">
<a href="{% url app_name|add:':exam_overview' pk=exam.pk %}">
{% if exam.exam_mode %}Exam:{% else %}Packet:{% endif %} {{ exam }}
</a>
<small class="ms-2 text-muted">
<span class="badge bg-secondary">Questions: {{ exam.get_questions|length }}</span>
<span class="badge bg-secondary ms-1">Candidates: {{ exam.get_candidate_count }}</span>
</small>
</h5>
<small class="text-muted">Authors: {{ exam.get_authors }}</small>
{% if exam.exam_mode %}
{% if app_name in "rapids longs anatomy" %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':mark_overview' pk=exam.pk %}">Mark answers</a>{% endif %}
{% endif %}
{% if request.user.is_staff %}<a href="{% url app_name|add:':exam_scores_all' pk=exam.pk %}">Scores</a>{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
@@ -163,3 +163,44 @@
.btn-light { background: #111827; color: #e6eef6; border-color: rgba(255,255,255,0.04); }
.filter-card label { color: #e6eef6; }
</style>
<!-- jQuery UI for calendar datepickers (only for the filter panel) -->
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css">
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"></script>
<script>
(function($){
$(function(){
// Initialize jQuery UI datepicker on inputs marked with .datepicker
$('.datepicker').each(function(){
// ensure keyboard-friendly placeholder
if(!$(this).attr('placeholder')) $(this).attr('placeholder','dd/mm/yyyy');
$(this).datepicker({
dateFormat: 'dd/mm/yy',
changeMonth: true,
changeYear: true,
showAnim: 'fadeIn'
});
});
});
})(jQuery);
</script>
<script>
(function($){
// Convert dd/mm/yyyy date inputs to ISO yyyy-mm-dd before form submit so Django parses correctly
$(function(){
$('.floating-filter form').on('submit', function(e){
$(this).find('input.datepicker').each(function(){
var v = (this.value || '').trim();
if(!v) return;
// match dd/mm/yyyy or d/m/yyyy
var m = v.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if(m){
var day = m[1].padStart(2,'0');
var month = m[2].padStart(2,'0');
var year = m[3];
this.value = year + '-' + month + '-' + day; // ISO format
}
});
});
});
})(jQuery);
</script>
+10
View File
@@ -333,6 +333,16 @@ def generic_view_urls(generic_views: GenericViewBase):
def generic_exam_urls(generic_exam_view: GenericExamViews):
urlpatterns = [
path(
"exam/bulk_update/",
generic_exam_view.exam_bulk_update,
name="exam_bulk_update",
),
path(
"exam/bulk_delete/",
generic_exam_view.exam_bulk_delete,
name="exam_bulk_delete",
),
path("", generic_exam_view.index, name="index"),
path("exam/<int:pk>/", generic_exam_view.exam_overview, name="exam_overview"),
path(
+221 -2
View File
@@ -43,7 +43,13 @@ from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic.list import ListView
from django.views.generic.detail import DetailView
from django_filters.views import FilterView
from django_filters import FilterSet, OrderingFilter, ModelMultipleChoiceFilter
from django_filters import (
FilterSet,
OrderingFilter,
ModelMultipleChoiceFilter,
DateFromToRangeFilter,
)
from django_filters.widgets import DateRangeWidget
from django_tables2.views import SingleTableMixin
from reversion.views import RevisionMixin
from atlas.models import CaseCollection, CaseDetail
@@ -61,7 +67,7 @@ from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
import zipfile
from django.core.files.base import ContentFile
from django.db.models import Q
from django.db.models import Q, Count, F
from .forms import (
CidGroupExamForm,
@@ -733,6 +739,18 @@ class ExamViews(View, LoginRequiredMixin):
fields=(("name", "name"), ("exam_mode", "exam_mode"))
)
# Allow filtering by start/end date ranges (render as paired date inputs)
start_date = DateFromToRangeFilter(
field_name="start_date",
label="Start date",
widget=DateRangeWidget(attrs={"class": "datepicker", "autocomplete": "off", "placeholder": "dd/mm/yyyy"}),
)
end_date = DateFromToRangeFilter(
field_name="end_date",
label="End date",
widget=DateRangeWidget(attrs={"class": "datepicker", "autocomplete": "off", "placeholder": "dd/mm/yyyy"}),
)
class Meta:
model = exam
fields = {
@@ -988,6 +1006,117 @@ class ExamViews(View, LoginRequiredMixin):
)
)
@method_decorator(login_required)
def exam_bulk_update(self, request):
"""HTMX endpoint to bulk-update exams (currently supports updating the date).
Expects POST with 'exam_ids' (multiple) and optional 'date' (YYYY-MM-DD).
Returns the updated exam list partial.
"""
if request.method != "POST":
return HttpResponse(status=405)
exam_ids = request.POST.getlist("exam_ids")
date_str = request.POST.get("date", "")
# Parse date if provided
new_date = None
if date_str:
try:
from datetime import date as _date
new_date = _date.fromisoformat(date_str)
except Exception:
new_date = None
# Find the queryset for the current user as index() does
if (
request.user.is_superuser
or (
self.app_name == "rapids"
and request.user.groups.filter(name="rapid_checker").exists()
)
or (
self.app_name == "anatomy"
and request.user.groups.filter(name="anatomy_checker").exists()
)
or (
self.app_name == "longs"
and request.user.groups.filter(name="long_checker").exists()
)
):
exams_qs = self.Exam.objects.all()
filter = self.ExtraExamFilter(request.GET, queryset=exams_qs)
else:
exams_qs = self.Exam.objects.filter(author__id=request.user.id) | self.Exam.objects.filter(open_access=True)
filter = self.BasicExamFilter(request.GET, queryset=exams_qs)
# Apply changes
for eid in exam_ids:
try:
exam = self.Exam.objects.get(pk=eid)
except self.Exam.DoesNotExist:
continue
if not self.check_user_edit_access(request.user, exam_id=exam.pk):
# Skip exams the user cannot edit
continue
if new_date is not None:
exam.start_date = new_date
exam.save()
return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
@method_decorator(login_required)
def exam_bulk_delete(self, request):
"""HTMX endpoint to bulk-delete selected exams. Expects POST 'exam_ids'.
Returns the updated exam list partial.
"""
if request.method != "POST":
return HttpResponse(status=405)
exam_ids = request.POST.getlist("exam_ids")
if (
request.user.is_superuser
or (
self.app_name == "rapids"
and request.user.groups.filter(name="rapid_checker").exists()
)
or (
self.app_name == "anatomy"
and request.user.groups.filter(name="anatomy_checker").exists()
)
or (
self.app_name == "longs"
and request.user.groups.filter(name="long_checker").exists()
)
):
exams_qs = self.Exam.objects.all()
filter = self.ExtraExamFilter(request.GET, queryset=exams_qs)
else:
exams_qs = self.Exam.objects.filter(author__id=request.user.id) | self.Exam.objects.filter(open_access=True)
filter = self.BasicExamFilter(request.GET, queryset=exams_qs)
for eid in exam_ids:
try:
exam = self.Exam.objects.get(pk=eid)
except self.Exam.DoesNotExist:
continue
if not self.check_user_edit_access(request.user, exam_id=exam.pk):
continue
try:
exam.delete()
except Exception:
# ignore individual delete failures
continue
return render(request, "generic/partials/_exam_list.html", {"filter": filter, "app_name": self.app_name})
@method_decorator(login_required)
def exam_toggle_results_published(self, request, pk):
if request.method == "POST":
@@ -4649,6 +4778,30 @@ class SupervisorList(CidManagerRequiredMixin, SingleTableMixin, FilterView):
class ExamCollectionList(ListView):
model = ExamCollection
def get_queryset(self):
"""Annotate exam counts per collection and prefetch authors to avoid N+1 queries in templates."""
qs = (
ExamCollection.objects.all()
.annotate(
anatomy_count=Count("anatomy_exams", filter=Q(anatomy_exams__archive=False)),
longs_count=Count("longs_exams", filter=Q(longs_exams__archive=False)),
physics_count=Count("physics_exams", filter=Q(physics_exams__archive=False)),
rapids_count=Count("rapids_exams", filter=Q(rapids_exams__archive=False)),
sbas_count=Count("sbas_exams", filter=Q(sbas_exams__archive=False)),
)
.annotate(
total_count=F("anatomy_count")
+F("longs_count")
+F("physics_count")
+F("rapids_count")
+F("sbas_count")
)
.prefetch_related("author")
.order_by("name")
)
return qs
class ExamCollectionDetail(DetailView, AuthorRequiredMixin):
model = ExamCollection
@@ -4709,6 +4862,72 @@ class ExamCollectionClone(CreateView, AuthorRequiredMixin):
object.author.set(self.author)
object.save()
# Clone exams linked to the original collection into the new one.
# For each supported exam app, find exams related to the old collection
# and create shallow copies (questions/authors re-applied, but
# active/archive/publish flags cleared).
if hasattr(self, "initial_object") and self.initial_object:
for _label, rel_name, ExamModel in GROUP_TYPES:
try:
old_exams = list(getattr(self.initial_object, rel_name).all())
except Exception:
old_exams = []
for old_exam in old_exams:
# Build a dict of field values excluding the PK
data = model_to_dict(old_exam, exclude=["id"])
# Clear group membership and flags for cloned exams
data["valid_user_users"] = [] if "valid_user_users" in data else []
data["valid_cid_users"] = [] if "valid_cid_users" in data else []
data["cid_user_groups"] = [] if "cid_user_groups" in data else []
data["user_user_groups"] = [] if "user_user_groups" in data else []
data["active"] = False
# some models use `archive` or `archived` - try both safely
if "archive" in data:
data["archive"] = False
if "archived" in data:
data["archived"] = False
data["publish_results"] = False if "publish_results" in data else data.get("publish_results", False)
# Keep a copy of M2M fields to reapply after create
m2m_backup = {}
for m2m_field in ("exam_questions", "author", "markers", "valid_user_users", "valid_cid_users", "cid_user_groups", "user_user_groups"):
if m2m_field in data:
m2m_backup[m2m_field] = data.pop(m2m_field)
# If the user supplied a date on the new collection, apply it to
# cloned exams so the cloned exams have the requested date.
if getattr(object, "date", None):
data["date"] = object.date
# Set the new examcollection foreign key to the newly created collection
# model_to_dict returns the FK id; replace with instance for create()
data["examcollection"] = object
try:
new_exam = ExamModel.objects.create(**data)
except Exception:
# If creation failed for any model-specific field, skip cloning this exam
continue
# Reapply M2M relationships where possible
for field_name, ids in m2m_backup.items():
try:
getattr(new_exam, field_name).set(ids)
except Exception:
# If the field doesn't exist on this model, ignore
pass
try:
# Some exam models provide an ordering helper
if hasattr(new_exam, "order_questions"):
new_exam.order_questions()
except Exception:
pass
return HttpResponseRedirect(object.get_absolute_url())
+1
View File
@@ -30,6 +30,7 @@
<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>
<li><a class="dropdown-item" href="{% url 'sbas:question_search_page' %}"><i class="bi bi-search"></i> Search Questions</a></li>
<!-- Additional items can be added here -->
</ul>
+12 -1
View File
@@ -2,14 +2,25 @@
{% block content %}
<h1>Import LLM Questions</h1>
<form id="llm-import-form" method="post" enctype="multipart/form-data" hx-post="{% url 'sbas:import_llm_questions' %}" hx-target="#import-preview" hx-swap="innerHTML">
<form id="llm-import-form" method="post" enctype="multipart/form-data" hx-post="{% url 'sbas:import_llm_questions' %}" hx-target="#import-preview" hx-swap="innerHTML" hx-indicator="#llm-import-indicator">
{% csrf_token %}
{{ form.as_p }}
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" type="submit">Preview (dry run)</button>
<button class="btn btn-secondary" type="button" onclick="document.getElementById('llm-import-form').reset()">Reset</button>
</div>
<div class="mt-2">
<span id="llm-import-indicator" aria-hidden="true" style="display:none;">
<span class="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</span>
</div>
</form>
<style>
/* HTMX indicator for the import form */
#llm-import-indicator { display: none; }
#llm-import-indicator.htmx-request { display: inline-block; }
</style>
<p>Upload a single JSON array, a single object, or JSONL (one JSON object per line) matching the agreed schema. Use the Preview button to inspect results before importing.</p>
<div id="import-preview" class="mt-3"></div>
{% endblock %}
+2
View File
@@ -221,6 +221,8 @@ document.addEventListener('DOMContentLoaded', function() {
URL.revokeObjectURL(url);
showFeedback('Downloaded');
});
}
// Handlers for the second prompt (llm-question-prompt-text)
const copyBtnText = document.getElementById('copy-llm-prompt-text');
@@ -0,0 +1,37 @@
{# partial: list of questions for HTMX search results #}
<div class="list-group">
{% if questions %}
{% for question in questions %}
<a href="{{ question.get_absolute_url }}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ question.title|default:question.get_stem_stripped|truncatechars:80 }}</h6>
<small class="text-muted">#{{ question.pk }}</small>
</div>
<div class="mb-1">
{% if question.author.all %}
<small class="text-muted me-2">By {{ question.author.all|join:", " }}</small>
{% endif %}
{% if question.category %}<span class="badge bg-secondary me-1">{{ question.category.category }}</span>{% endif %}
{% if question.frcr_appropriate %}<span class="badge bg-info text-dark me-1">FRCR</span>{% endif %}
</div>
<div class="mb-1">
{% for c in question.condition.all|slice:":3" %}
<span class="badge bg-light text-dark me-1">{{ c.name }}</span>
{% endfor %}
{% for p in question.presentation.all|slice:":3" %}
<span class="badge bg-light text-dark me-1">{{ p.name }}</span>
{% endfor %}
{% for s in question.subspecialty.all|slice:":3" %}
<span class="badge bg-light text-dark me-1">{{ s.name }}</span>
{% endfor %}
</div>
<p class="mb-1 text-truncate">{{ question.stem|striptags|truncatechars:160 }}</p>
</a>
{% endfor %}
{% else %}
<div class="list-group-item">No questions found</div>
{% endif %}
</div>
+41
View File
@@ -0,0 +1,41 @@
{% extends 'sbas/base.html' %}
{% block content %}
<div class="container my-4">
<div class="row">
<div class="col-12">
<h2 class="mb-3">SBAs Question Search</h2>
</div>
</div>
<div class="row">
<div class="col-12 col-md-8">
<div class="card">
<div class="card-body">
<h5 class="card-title">Search questions</h5>
<div class="mb-2">
<div class="input-group">
<input class="form-control" id="sbas-question-search-input" name="q" type="search"
placeholder="Search questions by stem, answers, category or tags"
hx-get="{% url 'sbas:question_search_partial' %}"
hx-trigger="input changed delay:400ms, keyup[key=='Enter']"
hx-target="#sbas-question-search-results"
hx-indicator="#sbas-question-search-indicator" />
<span class="input-group-text" id="sbas-question-search-indicator" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading</span>
</span>
</div>
</div>
<style>
#sbas-question-search-indicator { display: none; }
#sbas-question-search-indicator.htmx-request { display: inline-flex; align-items: center; }
</style>
<div id="sbas-question-search-results">
{% include 'sbas/partials/_question_search_results.html' with questions=None %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+2
View File
@@ -115,6 +115,8 @@ urlpatterns = [
views.question_overview,
name="question_overview",
),
path("search/", views.question_search_page, name="question_search_page"),
path("search/questions/", views.question_search_partial, name="question_search_partial"),
path("question/<int:pk>/toggle_frcr/", views.toggle_frcr_appropriate, name="toggle_frcr"),
path("category/merge/", views.merge_category, name="merge_category"),
#path(
+48
View File
@@ -120,6 +120,54 @@ def active_exams(request):
return render(request, "sbas/available_exam_list.html", {"exams": active_exams})
@login_required
def question_search_page(request):
"""Render the SBAs search page which contains the HTMX search input.
The actual results are returned by `question_search_partial` and swapped
into the results container via HTMX.
"""
return render(request, "sbas/search.html", {})
@login_required
def question_search_partial(request):
"""Return an HTMX partial listing questions matching `q`.
Searches several Question fields and related names. Uses `distinct()` to
avoid duplicates from joins. The template expects `questions` in context.
"""
q = request.GET.get("q", "") or request.GET.get("sbas-question-search-input", "")
q = q.strip()
qs = Question.objects.all()
if q:
from django.db.models import Q
qs = qs.filter(
Q(title__icontains=q)
| Q(stem__icontains=q)
| Q(a_answer__icontains=q)
| Q(b_answer__icontains=q)
| Q(c_answer__icontains=q)
| Q(d_answer__icontains=q)
| Q(e_answer__icontains=q)
| Q(category__category__icontains=q)
| Q(author__first_name__icontains=q)
| Q(author__last_name__icontains=q)
| Q(author__username__icontains=q)
| Q(condition__name__icontains=q)
| Q(presentation__name__icontains=q)
| Q(subspecialty__name__icontains=q)
).distinct()
# Prefetch related data used in the partial to avoid N+1 queries
qs = qs.select_related("category").prefetch_related("author", "condition", "presentation", "subspecialty")[:50]
return render(request, "sbas/partials/_question_search_results.html", {"questions": qs})
def exam_start(request, pk):
exam = get_object_or_404(Exam, pk=pk)
+5 -3
View File
@@ -201,7 +201,7 @@
<a class="nav-link active" href="{% url 'atlas:index' %}">Atlas</a>
</li>
<li class="nav-item dropdown pt-0">
<a class="nav-link dropdown-toggle active" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
FRCR
</a>
<ul class="dropdown-menu">
@@ -223,6 +223,9 @@
<li>
<a class="dropdown-item" href="{% url 'sbas:index' %}">SBAs</a>
</li>
<li>
<a class="dropdown-item" href="{% url 'generic:examcollection_list' %}">Exam Collection</a>
</li>
</ul>
</li>
{% if request.user|has_group:"cid_user_manager" %}
@@ -334,7 +337,6 @@ document.addEventListener('DOMContentLoaded', function() {
//}
// 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"]')); });
console.log('findTableForControl fallback', any);
return any || null;
}
@@ -375,7 +377,7 @@ document.addEventListener('DOMContentLoaded', function() {
container.parentNode.insertBefore(wrapper, container);
}
//// Create controls for any table that looks selectable but lacks a control block
// 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);