.
This commit is contained in:
+2
-2
@@ -81,7 +81,7 @@ from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidg
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget
|
||||
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget, ExamSearchWidget
|
||||
|
||||
|
||||
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
||||
@@ -869,7 +869,7 @@ class ExamCollectionForm(ModelForm):
|
||||
self.fields[reverse_prop] = ModelMultipleChoiceField(
|
||||
required=False,
|
||||
queryset=exam_obj.objects.filter(archive=False),
|
||||
widget=FilteredSelectMultiple(verbose_name=name, is_stacked=False),
|
||||
widget=ExamSearchWidget(exam_model=exam_obj),
|
||||
)
|
||||
|
||||
self.fields["author"] = ModelMultipleChoiceField(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{% if results %}
|
||||
<ul class="list-unstyled mb-0" id="exam_search_results_list_{{ field }}">
|
||||
{% 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 }}">
|
||||
<div class="flex-grow-1">
|
||||
{{ item.text|escape }}
|
||||
</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>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if results %}
|
||||
<div class="mt-2 text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-success exam-add-all-btn" data-field="{{ field }}">Add all results</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div><em>No exams found</em></div>
|
||||
{% endif %}
|
||||
@@ -59,6 +59,7 @@ urlpatterns = [
|
||||
),
|
||||
path("user-search/", views.user_search, name="user_search"),
|
||||
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
|
||||
path("exam-search-widget/", views.exam_search_widget, name="exam_search_widget"),
|
||||
path("supervisor-search/", views.supervisor_search, name="supervisor_search"),
|
||||
path(
|
||||
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
|
||||
|
||||
@@ -6045,6 +6045,113 @@ def user_search_widget(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def exam_search_widget(request):
|
||||
"""HTMX/JS endpoint to search exams for the exam-selection widget.
|
||||
|
||||
GET params:
|
||||
- q: query string
|
||||
- field: the form field name to return with the results (so the
|
||||
template can call addExamToField(field, id, text)).
|
||||
- model: optional model label ("app_label.ModelName") to restrict
|
||||
search to a specific exam model.
|
||||
|
||||
Returns a small HTML fragment listing matching exams. Each result will
|
||||
include a button with class `.exam-add-btn` and `data-exam-id`/`data-exam-text`.
|
||||
"""
|
||||
import re
|
||||
from django.apps import apps
|
||||
|
||||
q_raw = (request.GET.get("q") or "").strip()
|
||||
field = request.GET.get("field") or request.GET.get("row")
|
||||
model_label = request.GET.get("model")
|
||||
if not q_raw:
|
||||
return HttpResponse("")
|
||||
|
||||
# Support wildcard queries
|
||||
q = q_raw
|
||||
|
||||
model = None
|
||||
if model_label:
|
||||
try:
|
||||
# model_label may be 'app_label.ModelName' or the model label
|
||||
model = apps.get_model(model_label)
|
||||
except Exception:
|
||||
try:
|
||||
# try splitting
|
||||
app_label, model_name = model_label.split(".")
|
||||
model = apps.get_model(app_label, model_name)
|
||||
except Exception:
|
||||
model = None
|
||||
|
||||
# Build queryset
|
||||
qs = None
|
||||
if model is not None:
|
||||
# Try common name-like fields for exams
|
||||
from django.db.models import Q
|
||||
|
||||
field_lookups = ["name__icontains", "title__icontains", "examination__icontains", "modality__icontains"]
|
||||
for lookup in field_lookups:
|
||||
try:
|
||||
qs = model.objects.filter(**{lookup: q})
|
||||
if qs.exists():
|
||||
break
|
||||
except Exception:
|
||||
qs = None
|
||||
# fallback: try icontains on str() via name if present
|
||||
if qs is None or not qs.exists():
|
||||
try:
|
||||
qs = model.objects.filter(Q(name__icontains=q) | Q(title__icontains=q))
|
||||
except Exception:
|
||||
try:
|
||||
qs = model.objects.all()
|
||||
except Exception:
|
||||
qs = model.objects.none()
|
||||
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 = []
|
||||
for ml in possible_models:
|
||||
try:
|
||||
m = apps.get_model(*ml.split("."))
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
rqs = m.objects.filter(name__icontains=q)[:5]
|
||||
except Exception:
|
||||
try:
|
||||
rqs = m.objects.filter(title__icontains=q)[:5]
|
||||
except Exception:
|
||||
rqs = m.objects.none()
|
||||
for o in rqs:
|
||||
results.append(o)
|
||||
# Render combined results
|
||||
rendered_results = []
|
||||
for o in results[:50]:
|
||||
rendered_results.append({"id": getattr(o, "pk", None), "text": str(o)})
|
||||
return render(request, "generic/partials/exam_search_widget_results.html", {"results": rendered_results, "field": field, "q": q_raw})
|
||||
|
||||
# Limit results
|
||||
limit = 50 if q in ("*", "%") else 10
|
||||
try:
|
||||
qs = qs.order_by("name")[:limit]
|
||||
except Exception:
|
||||
qs = qs[:limit]
|
||||
|
||||
results = []
|
||||
for e in qs:
|
||||
results.append({"id": e.pk, "text": str(e)})
|
||||
|
||||
return render(request, "generic/partials/exam_search_widget_results.html", {"results": results, "field": field, "q": q_raw})
|
||||
|
||||
|
||||
@login_required
|
||||
def supervisor_search(request):
|
||||
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
||||
|
||||
+202
-1
@@ -179,6 +179,8 @@ class UserSearchSelectMultipleWidget:
|
||||
sel.appendChild(opt);
|
||||
|
||||
// add to visible list (include grade if provided)
|
||||
const listEl = document.getElementById('selected_users_' + fieldName);
|
||||
if (!listEl) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = 'selected_user_' + fieldName + '_' + id;
|
||||
@@ -187,7 +189,7 @@ class UserSearchSelectMultipleWidget:
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
||||
li.appendChild(span); li.appendChild(btn);
|
||||
selectedList.appendChild(li);
|
||||
listEl.appendChild(li);
|
||||
}}
|
||||
|
||||
window.removeUserFromField = function(fieldName, id) {{
|
||||
@@ -268,3 +270,202 @@ class UserSearchWidget:
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class ExamSearchSelectMultipleWidget:
|
||||
"""Lightweight renderer for selecting exams across types.
|
||||
|
||||
Parameters
|
||||
- name, value: same as form widget usage
|
||||
- exam_model: optional Django model class for an exam type (used to
|
||||
adapt display and search behaviour)
|
||||
- display_fields: optional list of extra fields to show in results
|
||||
"""
|
||||
|
||||
def __init__(self, name, value, exam_model=None, display_fields=None):
|
||||
self.name = name
|
||||
self.value = value or []
|
||||
self.exam_model = exam_model
|
||||
self.display_fields = display_fields or []
|
||||
|
||||
def render(self):
|
||||
field_id = f"id_{self.name}"
|
||||
search_id = f"exam_search_{self.name}"
|
||||
results_id = f"exam_search_results_{self.name}"
|
||||
selected_list_id = f"selected_exams_{self.name}"
|
||||
|
||||
# Render currently selected exams as <option>s
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
try:
|
||||
if self.value:
|
||||
from django.apps import apps
|
||||
|
||||
# Accept either pks or model instances
|
||||
instances = []
|
||||
for v in self.value:
|
||||
if hasattr(v, "pk"):
|
||||
instances.append(v)
|
||||
else:
|
||||
# try to resolve by pk across possible models
|
||||
# when exam_model is provided prefer that
|
||||
if self.exam_model is not None:
|
||||
try:
|
||||
inst = self.exam_model.objects.filter(pk=v).first()
|
||||
if inst:
|
||||
instances.append(inst)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# fallback: try generic lookup across known exam models is expensive;
|
||||
# just attempt to treat v as a pk for display-less option
|
||||
instances.append(type("_", (), {"pk": v, "__str__": lambda s: str(v)}))
|
||||
|
||||
for ex in instances:
|
||||
label = str(ex)
|
||||
options_html += f'<option value="{getattr(ex, "pk", "")}" selected>{label}</option>'
|
||||
selected_html += (
|
||||
f'<li id="selected_exam_{self.name}_{getattr(ex, "pk", "")}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
f'<span>{label}</span>'
|
||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeExamFromField(\'{self.name}\', {getattr(ex, "pk", "")})">Remove</button>'
|
||||
f'</li>'
|
||||
)
|
||||
except Exception:
|
||||
options_html = ""
|
||||
selected_html = ""
|
||||
|
||||
url = reverse("generic:exam_search_widget") if reverse else ""
|
||||
|
||||
html = f"""
|
||||
<div class="exam-search-widget">
|
||||
<select name="{self.name}" id="{field_id}" multiple class="d-none">
|
||||
{options_html}
|
||||
</select>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search exams by name or code" />
|
||||
<!-- optional type indicator -->
|
||||
{f'<div class="badge bg-secondary small">{self.exam_model._meta.verbose_name.title()}</div>' if self.exam_model is not None else ''}
|
||||
</div>
|
||||
<div id="{results_id}" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label small">Selected exams</label>
|
||||
<ul id="{selected_list_id}" class="list-group list-group-flush">
|
||||
{selected_html}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
const search = document.getElementById('{search_id}');
|
||||
const results = document.getElementById('{results_id}');
|
||||
const select = document.getElementById('{field_id}');
|
||||
const selectedList = document.getElementById('{selected_list_id}');
|
||||
|
||||
let timeout = null;
|
||||
function fetchResults(q) {{
|
||||
if (!q || q.trim().length === 0) {{
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}}
|
||||
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + ("&model=" + encodeURIComponent('{self.exam_model._meta.label}') || '');
|
||||
fetch(url)
|
||||
.then(r => r.text())
|
||||
.then(html => {{ results.innerHTML = html; }});
|
||||
}}
|
||||
|
||||
search.addEventListener('keyup', function(e) {{
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||
}});
|
||||
|
||||
results.addEventListener('click', function(e) {{
|
||||
const btn = e.target.closest('.exam-add-btn');
|
||||
if (!btn) return;
|
||||
const id = btn.getAttribute('data-exam-id');
|
||||
const text = btn.getAttribute('data-exam-text') || btn.textContent.trim();
|
||||
addExamToField('{self.name}', id, text);
|
||||
}});
|
||||
|
||||
window.addExamToField = function(fieldName, id, text) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
|
||||
const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
|
||||
sel.appendChild(opt);
|
||||
const listEl = document.getElementById('selected_exams_' + fieldName);
|
||||
if (!listEl) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
li.id = 'selected_exam_' + fieldName + '_' + id;
|
||||
const span = document.createElement('span'); span.innerHTML = text;
|
||||
const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
|
||||
btn.addEventListener('click', function() {{ removeExamFromField(fieldName, id); }});
|
||||
li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
|
||||
}}
|
||||
|
||||
window.removeExamFromField = function(fieldName, id) {{
|
||||
const sel = document.getElementById('id_' + fieldName);
|
||||
if (!sel) return;
|
||||
for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
|
||||
const li = document.getElementById('selected_exam_' + fieldName + '_' + id);
|
||||
if (li && li.parentNode) li.parentNode.removeChild(li);
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
class ExamSearchWidget:
|
||||
"""Widget wrapper that is compatible with Django form machinery.
|
||||
|
||||
Accepts `exam_model` to specialise behaviour/display.
|
||||
"""
|
||||
|
||||
is_hidden = False
|
||||
needs_multipart_form = False
|
||||
use_fieldset = False
|
||||
|
||||
def __init__(self, field=None, exam_model=None, display_fields=None):
|
||||
self.field = field
|
||||
self.attrs = {}
|
||||
self.exam_model = exam_model
|
||||
self.display_fields = display_fields or []
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
self.attrs = attrs or {}
|
||||
value = value or []
|
||||
widget = ExamSearchSelectMultipleWidget(name, value, exam_model=self.exam_model, display_fields=self.display_fields)
|
||||
return widget.render()
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
if hasattr(data, 'getlist'):
|
||||
return data.getlist(name)
|
||||
val = data.get(name)
|
||||
if val is None:
|
||||
return []
|
||||
return [val]
|
||||
|
||||
def id_for_label(self, id_):
|
||||
try:
|
||||
if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
|
||||
return self.attrs.get('id')
|
||||
except Exception:
|
||||
pass
|
||||
return id_
|
||||
|
||||
def use_required_attribute(self, initial):
|
||||
try:
|
||||
if hasattr(self, 'field') and getattr(self, 'field') is not None:
|
||||
orig_widget = getattr(self.field, 'widget', None)
|
||||
if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
|
||||
return orig_widget.use_required_attribute(initial)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user