.
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.utils.safestring import mark_safe
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget
|
from generic.widgets import UserSearchWidget, UserSearchSelectMultipleWidget, ExamSearchWidget
|
||||||
|
|
||||||
|
|
||||||
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
||||||
@@ -869,7 +869,7 @@ class ExamCollectionForm(ModelForm):
|
|||||||
self.fields[reverse_prop] = ModelMultipleChoiceField(
|
self.fields[reverse_prop] = ModelMultipleChoiceField(
|
||||||
required=False,
|
required=False,
|
||||||
queryset=exam_obj.objects.filter(archive=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(
|
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/", views.user_search, name="user_search"),
|
||||||
path("user-search-widget/", views.user_search_widget, name="user_search_widget"),
|
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("supervisor-search/", views.supervisor_search, name="supervisor_search"),
|
||||||
path(
|
path(
|
||||||
"cids/manage/<int:pk>/update", views.CidUserUpdate.as_view(), name="update_cid"
|
"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
|
@login_required
|
||||||
def supervisor_search(request):
|
def supervisor_search(request):
|
||||||
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
|
||||||
|
|||||||
+202
-1
@@ -179,6 +179,8 @@ class UserSearchSelectMultipleWidget:
|
|||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
|
|
||||||
// add to visible list (include grade if provided)
|
// add to visible list (include grade if provided)
|
||||||
|
const listEl = document.getElementById('selected_users_' + fieldName);
|
||||||
|
if (!listEl) return;
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
li.id = 'selected_user_' + fieldName + '_' + id;
|
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';
|
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); }});
|
btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
|
||||||
li.appendChild(span); li.appendChild(btn);
|
li.appendChild(span); li.appendChild(btn);
|
||||||
selectedList.appendChild(li);
|
listEl.appendChild(li);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
window.removeUserFromField = function(fieldName, id) {{
|
window.removeUserFromField = function(fieldName, id) {{
|
||||||
@@ -268,3 +270,202 @@ class UserSearchWidget:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return False
|
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