Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b424defd57 | ||
|
|
00a675b715 | ||
|
|
bd0859cce0 | ||
|
|
2bf74ea297 | ||
|
|
71e9b47c6b | ||
|
|
9f8b4ff36a | ||
|
|
9b8e6f1398 | ||
|
|
0ff60da006 | ||
|
|
53e18e296e | ||
|
|
8f2ca66bbb | ||
|
|
ebad9c6bd9 | ||
|
|
54afa6e6bd | ||
|
|
17541b8187 | ||
|
|
04ce90049a | ||
|
|
2d963259ca | ||
|
|
ccb27af697 | ||
|
|
dfdba4cce6 | ||
|
|
b12a45275c | ||
|
|
4f6e9fae9d | ||
|
|
22299b9298 | ||
|
|
06df2c4188 | ||
|
|
8c1c7b75e7 | ||
|
|
03a24615ab | ||
|
|
89a767034c |
+1
-1
@@ -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
|
||||
|
||||
@@ -1,49 +1,76 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2 class="mb-3">Atlas</h2>
|
||||
<div class="container my-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2 class="mb-3">Atlas</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Collections</h5>
|
||||
<p class="card-text">Quick access to collections you can view or take.</p>
|
||||
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">My cases / collections</h5>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
|
||||
<p>
|
||||
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Collections</h5>
|
||||
<p class="card-text">Quick access to collections you can view or take.</p>
|
||||
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">My cases / collections</h5>
|
||||
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
|
||||
<p>
|
||||
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
|
||||
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
|
||||
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
|
||||
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
|
||||
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
{% endfor %}
|
||||
<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>
|
||||
@@ -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
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user