This commit is contained in:
Ross
2025-12-08 11:51:36 +00:00
parent eb06e63f77
commit 9a3a8b932a
3 changed files with 178 additions and 17 deletions
@@ -1,9 +1,16 @@
{% if results %} {% if results %}
<ul class="list-unstyled mb-0" id="exam_search_results_list_{{ field }}"> <ul class="list-unstyled mb-0" id="exam_search_results_list_{{ field }}">
{% for item in results %} {% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}"> <li class="py-1 d-flex justify-content-between align-items-center"
data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}"
data-exam-archive="{{ item.archive|yesno:'1,0' }}" data-exam-open-access="{{ item.open_access|yesno:'1,0' }}"
data-exam-exam-mode="{{ item.exam_mode|yesno:'1,0' }}" data-exam-candidates-only="{{ item.candidates_only|yesno:'1,0' }}">
<div class="flex-grow-1"> <div class="flex-grow-1">
{{ item.text|escape }} {{ item.text|escape }}
{% if item.archive %}<span class="badge bg-secondary ms-2">Archived</span>{% endif %}
{% if item.open_access %}<span class="badge bg-success ms-2">Open</span>{% endif %}
{% if item.exam_mode %}<span class="badge bg-info text-dark ms-2">Exam mode</span>{% endif %}
{% if item.candidates_only %}<span class="badge bg-warning text-dark ms-2">Candidates only</span>{% endif %}
</div> </div>
<div> <div>
<button type="button" class="btn btn-sm btn-primary exam-add-btn" data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}">Add</button> <button type="button" class="btn btn-sm btn-primary exam-add-btn" data-exam-id="{{ item.id }}" data-exam-text="{{ item.text|escapejs }}">Add</button>
+137 -12
View File
@@ -6084,6 +6084,13 @@ def exam_search_widget(request):
except Exception: except Exception:
model = None model = None
# Helper to parse optional boolean GET params
def _get_bool_param(name):
v = request.GET.get(name)
if v is None:
return None
return str(v).lower() in ("1", "true", "yes", "on")
# Build queryset # Build queryset
qs = None qs = None
if model is not None: if model is not None:
@@ -6118,20 +6125,34 @@ def exam_search_widget(request):
"sbas.Exam", "sbas.Exam",
] ]
results = [] results = []
# gather optional filters from GET params
extra_filters = {}
for pname in ("archive", "open_access", "exam_mode", "candidates_only"):
val = _get_bool_param(pname)
if val is not None:
extra_filters[pname] = val
for ml in possible_models: for ml in possible_models:
try: try:
m = apps.get_model(*ml.split(".")) m = apps.get_model(*ml.split("."))
except Exception: except Exception:
continue continue
try: try:
rqs = m.objects.filter(name__icontains=q)[:5] # try common name-like fields and apply extra_filters when possible
except Exception:
try: try:
rqs = m.objects.filter(title__icontains=q)[:5] rqs = m.objects.filter(name__icontains=q, **extra_filters)[:5]
except Exception: except Exception:
rqs = m.objects.none() try:
rqs = m.objects.filter(title__icontains=q, **extra_filters)[:5]
except Exception:
# fallback to unfiltered small slice
rqs = m.objects.filter(name__icontains=q)[:5]
except Exception:
rqs = m.objects.none()
for o in rqs: for o in rqs:
results.append(o) results.append(o)
# Render combined results, but restrict to exams the user can access # Render combined results, but restrict to exams the user can access
def user_can_view_exam(exam, user): def user_can_view_exam(exam, user):
try: try:
@@ -6160,7 +6181,14 @@ def exam_search_widget(request):
rendered_results = [] rendered_results = []
for o in results[:50]: for o in results[:50]:
if user_can_view_exam(o, request.user): if user_can_view_exam(o, request.user):
rendered_results.append({"id": getattr(o, "pk", None), "text": str(o)}) rendered_results.append({
"id": getattr(o, "pk", None),
"text": str(o),
"archive": getattr(o, "archive", False),
"open_access": getattr(o, "open_access", False),
"exam_mode": getattr(o, "exam_mode", False),
"candidates_only": getattr(o, "candidates_only", False),
})
return render(request, "generic/partials/exam_search_widget_results.html", {"results": rendered_results, "field": field, "q": q_raw}) return render(request, "generic/partials/exam_search_widget_results.html", {"results": rendered_results, "field": field, "q": q_raw})
# Limit results # Limit results
@@ -6200,10 +6228,36 @@ def exam_search_widget(request):
pass pass
return False return False
# Apply optional filters from GET params to the per-model queryset
extra_filters = {}
for pname in ("archive", "open_access", "exam_mode", "candidates_only"):
val = _get_bool_param(pname)
if val is not None:
extra_filters[pname] = val
results = [] results = []
for e in qs: for e in qs:
# apply any extra_filters defensively (models may not have fields)
skip = False
for k, v in extra_filters.items():
try:
if getattr(e, k, None) != v:
skip = True
break
except Exception:
# if attribute access fails, don't skip
continue
if skip:
continue
if user_can_view_exam(e, request.user): if user_can_view_exam(e, request.user):
results.append({"id": e.pk, "text": str(e)}) results.append({
"id": e.pk,
"text": str(e),
"archive": getattr(e, "archive", False),
"open_access": getattr(e, "open_access", False),
"exam_mode": getattr(e, "exam_mode", False),
"candidates_only": getattr(e, "candidates_only", False),
})
return render(request, "generic/partials/exam_search_widget_results.html", {"results": results, "field": field, "q": q_raw}) return render(request, "generic/partials/exam_search_widget_results.html", {"results": results, "field": field, "q": q_raw})
@@ -6223,14 +6277,85 @@ def supervisor_search(request):
q = (request.GET.get("q") or "").strip() q = (request.GET.get("q") or "").strip()
row = request.GET.get("row") row = request.GET.get("row")
if not q: if not q:
return HttpResponse("") else:
# If no model specified, try to search across several known exam models
possible_models = [
"anatomy.Exam",
"longs.Exam",
"rapids.Exam",
"shorts.Exam",
"physics.Exam",
"sbas.Exam",
]
results = []
supervisors_qs = Supervisor.objects.filter( # gather optional filters from GET params
Q(name__icontains=q) | Q(email__icontains=q) extra_filters = {}
).order_by("name")[:10] for pname in ("archive", "open_access", "exam_mode", "candidates_only"):
val = _get_bool_param(pname)
if val is not None:
extra_filters[pname] = val
results = [{"id": s.pk, "text": f"{s.name} - {s.email or ''}"} for s in supervisors_qs] for ml in possible_models:
try:
m = apps.get_model(*ml.split("."))
except Exception:
continue
try:
# try common name-like fields and apply extra_filters when possible
try:
rqs = m.objects.filter(name__icontains=q, **extra_filters)[:5]
except Exception:
try:
rqs = m.objects.filter(title__icontains=q, **extra_filters)[:5]
except Exception:
# fallback to unfiltered small slice
rqs = m.objects.filter(name__icontains=q)[:5]
except Exception:
rqs = m.objects.none()
for o in rqs:
results.append(o)
return render(request, "generic/partials/supervisor_search_results.html", {"results": results, "row": row, "q": q}) # Render combined results, but restrict to exams the user can access
def user_can_view_exam(exam, user):
try:
if user.is_superuser:
return True
except Exception:
pass
try:
if getattr(exam, "open_access", False) and getattr(exam, "active", False):
return True
except Exception:
pass
try:
authors = exam.get_author_objects()
if user in authors:
return True
except Exception:
pass
try:
if hasattr(exam, "cid_user_exam") and exam.cid_user_exam.filter(user_user=user).exists():
return True
except Exception:
pass
try:
if hasattr(exam, "markers") and user in exam.markers.all():
return True
except Exception:
pass
return False
rendered_results = []
for o in results[:50]:
if user_can_view_exam(o, request.user):
rendered_results.append({
"id": getattr(o, "pk", None),
"text": str(o),
"archive": getattr(o, "archive", False),
"open_access": getattr(o, "open_access", False),
"exam_mode": getattr(o, "exam_mode", False),
"candidates_only": getattr(o, "candidates_only", False),
})
return render(request, "generic/partials/exam_search_widget_results.html", {"results": rendered_results, "field": field, "q": q_raw})
+32 -3
View File
@@ -353,6 +353,27 @@ class ExamSearchSelectMultipleWidget:
<div id="exam_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;"> <div id="exam_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong> <strong>Advanced search tips</strong>
<div class="mb-2 mt-2">
<strong class="small">Filters</strong>
<div class="d-flex gap-2 mt-1">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_archive_{self.name}" />
<label class="form-check-label small" for="filter_archive_{self.name}">Archived only</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_open_access_{self.name}" />
<label class="form-check-label small" for="filter_open_access_{self.name}">Open access only</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_exam_mode_{self.name}" />
<label class="form-check-label small" for="filter_exam_mode_{self.name}">Exam mode only</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter_candidates_only_{self.name}" />
<label class="form-check-label small" for="filter_candidates_only_{self.name}">Candidates only</label>
</div>
</div>
</div>
<ul class="mb-0 mt-1"> <ul class="mb-0 mt-1">
<li>Basic: type any part of an exam's name, code or identifier (case-insensitive substring match).</li> <li>Basic: type any part of an exam's name, code or identifier (case-insensitive substring match).</li>
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields: <li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
@@ -364,7 +385,6 @@ class ExamSearchSelectMultipleWidget:
<li><code>type:</code> narrow to an exam type (e.g. <code>type:anatomy</code>).</li> <li><code>type:</code> narrow to an exam type (e.g. <code>type:anatomy</code>).</li>
</ul> </ul>
</li> </li>
<li>Type filter: use the dropdown to restrict results to a specific exam type in addition to your query.</li>
<li>To add exams, click the <em>Add</em> button beside a result. Use <em>Add all results</em> to import visible matches.</li> <li>To add exams, click the <em>Add</em> button beside a result. Use <em>Add all results</em> to import visible matches.</li>
<li>Wildcard: use <code>*</code> to broaden results; wildcards may return more results and are rate-limited.</li> <li>Wildcard: use <code>*</code> to broaden results; wildcards may return more results and are rate-limited.</li>
</ul> </ul>
@@ -396,8 +416,17 @@ class ExamSearchSelectMultipleWidget:
results.innerHTML = ''; results.innerHTML = '';
return; return;
}} }}
const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : ''; const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : '';
const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(q) + modelParam; const f_archive = document.getElementById('filter_archive_{self.name}');
const f_open = document.getElementById('filter_open_access_{self.name}');
const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}');
const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
let extra = '';
if (f_archive && f_archive.checked) extra += '&archive=1';
if (f_open && f_open.checked) extra += '&open_access=1';
if (f_exam_mode && f_exam_mode.checked) extra += '&exam_mode=1';
if (f_candidates && f_candidates.checked) extra += '&candidates_only=1';
const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(q) + modelParam + extra;
fetch(url) fetch(url)
.then(r => r.text()) .then(r => r.text())
.then(html => {{ results.innerHTML = html; }}); .then(html => {{ results.innerHTML = html; }});