Add user authentication to index and user collections views; update CSRF trusted origins and internal IPs
This commit is contained in:
@@ -1090,10 +1090,12 @@ def author_list(request):
|
|||||||
return render(request, "atlas/author_list.html", {"authors": authors})
|
return render(request, "atlas/author_list.html", {"authors": authors})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def index(request):
|
def index(request):
|
||||||
return render(request, "atlas/index.html", {})
|
return render(request, "atlas/index.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def user_collections(request):
|
def user_collections(request):
|
||||||
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
|
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
|
||||||
available_collections = request.user.user_casecollection_exams.all()
|
available_collections = request.user.user_casecollection_exams.all()
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ SECURE_HSTS_SECONDS = 0
|
|||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
"http://127.0.0.1:8000",
|
"http://127.0.0.1:8000",
|
||||||
"http://localhost:8000",
|
"http://localhost:8000",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://127.0.0.1:8080/",
|
||||||
|
"http://localhost:8080/",
|
||||||
]
|
]
|
||||||
|
|
||||||
REMOTE_URL = "http://127.0.0.1:8000"
|
REMOTE_URL = "http://127.0.0.1:8000"
|
||||||
+7
-270
@@ -81,6 +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
|
||||||
|
|
||||||
|
|
||||||
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
class SplitDateTimeFieldDefaultTime(SplitDateTimeField):
|
||||||
@@ -240,7 +241,7 @@ class ExamAuthorFormMixin(ModelForm):
|
|||||||
ModelForm.__init__(self, *args, **kwargs)
|
ModelForm.__init__(self, *args, **kwargs)
|
||||||
self.fields["author"] = ModelMultipleChoiceField(
|
self.fields["author"] = ModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False),
|
widget=UserSearchWidget(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ class ExamMarkerFormMixin(ModelForm):
|
|||||||
ModelForm.__init__(self, *args, **kwargs)
|
ModelForm.__init__(self, *args, **kwargs)
|
||||||
self.fields["markers"] = ModelMultipleChoiceField(
|
self.fields["markers"] = ModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
widget=FilteredSelectMultiple(verbose_name="Markers", is_stacked=False),
|
widget=UserSearchWidget(),
|
||||||
)
|
)
|
||||||
self.fields["markers"].required = False
|
self.fields["markers"].required = False
|
||||||
|
|
||||||
@@ -556,219 +557,7 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
|
|||||||
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
|
||||||
|
|
||||||
|
|
||||||
class UserSearchSelectMultipleWidget:
|
|
||||||
"""Reusable lightweight widget renderer for a searchable multi-user selector.
|
|
||||||
|
|
||||||
Usage: instantiate with (name, value) where value is an iterable of user ids
|
|
||||||
and call .render() to get the HTML fragment. This mirrors the previous
|
|
||||||
nested widget but is available module-wide for reuse.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name, value):
|
|
||||||
self.name = name
|
|
||||||
self.value = value or []
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
field_id = f"id_{self.name}"
|
|
||||||
search_id = f"user_search_{self.name}"
|
|
||||||
results_id = f"user_search_results_{self.name}"
|
|
||||||
selected_list_id = f"selected_users_{self.name}"
|
|
||||||
|
|
||||||
users = User.objects.filter(pk__in=self.value) if self.value else []
|
|
||||||
options_html = ""
|
|
||||||
selected_html = ""
|
|
||||||
for u in users:
|
|
||||||
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
|
||||||
grade_text = ""
|
|
||||||
try:
|
|
||||||
up = getattr(u, "userprofile", None)
|
|
||||||
if up and getattr(up, "grade", None):
|
|
||||||
g = up.grade
|
|
||||||
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
|
||||||
except Exception:
|
|
||||||
grade_text = ""
|
|
||||||
|
|
||||||
selected_html += (
|
|
||||||
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
|
||||||
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
|
||||||
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
|
||||||
+ '</span>'
|
|
||||||
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
|
||||||
f'</li>'
|
|
||||||
)
|
|
||||||
|
|
||||||
url = reverse("generic:user_search_widget")
|
|
||||||
|
|
||||||
grades_options = '<option value="">All grades</option>'
|
|
||||||
try:
|
|
||||||
from generic.models import UserGrades
|
|
||||||
|
|
||||||
grades_qs = UserGrades.objects.all()
|
|
||||||
for g in grades_qs:
|
|
||||||
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
|
||||||
except Exception:
|
|
||||||
grades_options = '<option value="">All grades</option>'
|
|
||||||
|
|
||||||
html = f"""
|
|
||||||
<div class="user-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 users by name, username or email" />
|
|
||||||
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
|
||||||
{grades_options}
|
|
||||||
</select>
|
|
||||||
<!-- Help button for advanced search features -->
|
|
||||||
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="{results_id}" class="mt-2"></div>
|
|
||||||
|
|
||||||
<!-- Collapsible help panel (hidden by default) -->
|
|
||||||
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
|
|
||||||
<strong>Advanced search tips</strong>
|
|
||||||
<ul class="mb-0 mt-1">
|
|
||||||
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
|
|
||||||
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
|
|
||||||
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
|
|
||||||
<ul class="mb-0 mt-1">
|
|
||||||
<li><code>name:</code> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
|
|
||||||
<li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
|
|
||||||
<li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
|
|
||||||
<li><code>email:</code> — matches email address.</li>
|
|
||||||
<li><code>username:</code> — matches username.</li>
|
|
||||||
<li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
|
|
||||||
<li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
|
|
||||||
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
|
|
||||||
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="form-label small">Selected users</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}');
|
|
||||||
const gradeSelect = document.getElementById('grade_filter_{self.name}');
|
|
||||||
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
|
|
||||||
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
|
|
||||||
|
|
||||||
let timeout = null;
|
|
||||||
function fetchResults(q) {{
|
|
||||||
const grade = gradeSelect ? gradeSelect.value : '';
|
|
||||||
if (!q || q.trim().length === 0) {{
|
|
||||||
results.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
|
|
||||||
fetch(url)
|
|
||||||
.then(r => r.text())
|
|
||||||
.then(html => {{ results.innerHTML = html; }});
|
|
||||||
}}
|
|
||||||
|
|
||||||
search.addEventListener('keyup', function(e) {{
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(() => fetchResults(search.value), 300);
|
|
||||||
}});
|
|
||||||
|
|
||||||
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
|
||||||
// re-run search with same query when grade changes
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(() => fetchResults(search.value), 50);
|
|
||||||
}});
|
|
||||||
|
|
||||||
// Toggle help panel visibility
|
|
||||||
if (helpBtn && helpPanel) {{
|
|
||||||
helpBtn.addEventListener('click', function(e) {{
|
|
||||||
e.preventDefault();
|
|
||||||
helpPanel.classList.toggle('d-none');
|
|
||||||
const expanded = !helpPanel.classList.contains('d-none');
|
|
||||||
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
|
|
||||||
// fieldName for this widget instance
|
|
||||||
const widgetFieldName = '{self.name}';
|
|
||||||
|
|
||||||
// Add button click delegation: results list buttons have data attributes
|
|
||||||
results.addEventListener('click', function(e) {{
|
|
||||||
const btn = e.target.closest('.user-add-btn');
|
|
||||||
if (!btn) return;
|
|
||||||
const id = btn.getAttribute('data-user-id');
|
|
||||||
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
|
|
||||||
const grade = btn.getAttribute('data-user-grade') || '';
|
|
||||||
addUserToField(widgetFieldName, id, text, grade);
|
|
||||||
}});
|
|
||||||
|
|
||||||
// Wire up the "Add all results" button if present
|
|
||||||
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
|
|
||||||
if (addAllBtn) {{
|
|
||||||
addAllBtn.addEventListener('click', function(e) {{
|
|
||||||
e.preventDefault();
|
|
||||||
addAllFromResults(widgetFieldName);
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
|
|
||||||
window.addUserToField = function(fieldName, id, text, grade) {{
|
|
||||||
const sel = document.getElementById('id_' + fieldName);
|
|
||||||
if (!sel) return;
|
|
||||||
// prevent duplicate
|
|
||||||
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);
|
|
||||||
|
|
||||||
// add to visible list (include grade if provided)
|
|
||||||
const li = document.createElement('li');
|
|
||||||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
|
||||||
li.id = 'selected_user_' + fieldName + '_' + id;
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
|
|
||||||
window.removeUserFromField = 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_user_' + fieldName + '_' + id);
|
|
||||||
if (li && li.parentNode) li.parentNode.removeChild(li);
|
|
||||||
}}
|
|
||||||
|
|
||||||
window.addAllFromResults = function(fieldName) {{
|
|
||||||
const list = document.getElementById('user_search_results_list_' + fieldName);
|
|
||||||
if (!list) return;
|
|
||||||
const items = list.querySelectorAll('li[data-user-id]');
|
|
||||||
items.forEach(function(it) {{
|
|
||||||
const id = it.getAttribute('data-user-id');
|
|
||||||
const text = it.getAttribute('data-user-text') || it.textContent.trim();
|
|
||||||
const grade = it.getAttribute('data-user-grade') || '';
|
|
||||||
addUserToField(fieldName, id, text, grade);
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
|
|
||||||
return mark_safe(html)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -789,61 +578,9 @@ class UserUserGroupForm(ModelForm):
|
|||||||
if 'users' in self.fields:
|
if 'users' in self.fields:
|
||||||
orig_field = self.fields['users']
|
orig_field = self.fields['users']
|
||||||
|
|
||||||
class RenderWrapper:
|
# Use the reusable UserSearchWidget wrapper so templates and other
|
||||||
def __init__(self, field):
|
# modules can reuse the same behaviour consistently.
|
||||||
self.field = field
|
self.fields['users'].widget = UserSearchWidget(orig_field)
|
||||||
|
|
||||||
# Minimal widget-compatible wrapper used by Django templates and
|
|
||||||
# form machinery. We implement the small subset of the Widget
|
|
||||||
# API that the templates/checks expect: `is_hidden`,
|
|
||||||
# `needs_multipart_form`, `render(...)` and `value_from_datadict(...)`.
|
|
||||||
is_hidden = False
|
|
||||||
needs_multipart_form = False
|
|
||||||
use_fieldset = False
|
|
||||||
# attrs is expected by BoundField.id_for_label (widget.attrs.get('id'))
|
|
||||||
attrs = {}
|
|
||||||
|
|
||||||
def render(self, name, value, attrs=None, renderer=None):
|
|
||||||
# store attrs so template helpers can inspect them
|
|
||||||
self.attrs = attrs or {}
|
|
||||||
value = value or []
|
|
||||||
widget = UserSearchSelectMultipleWidget(name, value)
|
|
||||||
return widget.render()
|
|
||||||
|
|
||||||
def value_from_datadict(self, data, files, name):
|
|
||||||
# data is usually QueryDict with getlist
|
|
||||||
if hasattr(data, 'getlist'):
|
|
||||||
return data.getlist(name)
|
|
||||||
# fallback
|
|
||||||
val = data.get(name)
|
|
||||||
if val is None:
|
|
||||||
return []
|
|
||||||
return [val]
|
|
||||||
|
|
||||||
def id_for_label(self, id_):
|
|
||||||
# Called by BoundField.id_for_label; prefer explicit id in attrs
|
|
||||||
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):
|
|
||||||
# Called by Django to decide whether to add the required HTML attribute.
|
|
||||||
# We defer to the underlying field/widget if available; otherwise
|
|
||||||
# return False to avoid unexpected 'required' attributes.
|
|
||||||
try:
|
|
||||||
if hasattr(self, 'field') and getattr(self, 'field') is not None:
|
|
||||||
# If original field/widget had such method, prefer it.
|
|
||||||
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
|
|
||||||
|
|
||||||
self.fields['users'].widget = RenderWrapper(orig_field)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserUserGroup
|
model = UserUserGroup
|
||||||
@@ -1137,7 +874,7 @@ class ExamCollectionForm(ModelForm):
|
|||||||
|
|
||||||
self.fields["author"] = ModelMultipleChoiceField(
|
self.fields["author"] = ModelMultipleChoiceField(
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
widget=FilteredSelectMultiple(verbose_name="Authors", is_stacked=False), required=False,
|
widget=UserSearchWidget(), required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
|
||||||
|
class UserSearchSelectMultipleWidget:
|
||||||
|
"""Reusable lightweight widget renderer for a searchable multi-user selector.
|
||||||
|
|
||||||
|
Usage: instantiate with (name, value) where value is an iterable of user ids
|
||||||
|
and call .render() to get the HTML fragment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name, value):
|
||||||
|
self.name = name
|
||||||
|
self.value = value or []
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
field_id = f"id_{self.name}"
|
||||||
|
search_id = f"user_search_{self.name}"
|
||||||
|
results_id = f"user_search_results_{self.name}"
|
||||||
|
selected_list_id = f"selected_users_{self.name}"
|
||||||
|
|
||||||
|
users = User.objects.filter(pk__in=self.value) if self.value else []
|
||||||
|
options_html = ""
|
||||||
|
selected_html = ""
|
||||||
|
for u in users:
|
||||||
|
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
|
||||||
|
grade_text = ""
|
||||||
|
try:
|
||||||
|
up = getattr(u, "userprofile", None)
|
||||||
|
if up and getattr(up, "grade", None):
|
||||||
|
g = up.grade
|
||||||
|
grade_text = getattr(g, "name", str(g)) if g is not None else ""
|
||||||
|
except Exception:
|
||||||
|
grade_text = ""
|
||||||
|
|
||||||
|
selected_html += (
|
||||||
|
f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
|
||||||
|
f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
|
||||||
|
+ (f' <small class="text-muted ms-2">{grade_text}</small>' if grade_text else '')
|
||||||
|
+ '</span>'
|
||||||
|
f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
|
||||||
|
f'</li>'
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse("generic:user_search_widget")
|
||||||
|
|
||||||
|
grades_options = '<option value="">All grades</option>'
|
||||||
|
try:
|
||||||
|
from generic.models import UserGrades
|
||||||
|
|
||||||
|
grades_qs = UserGrades.objects.all()
|
||||||
|
for g in grades_qs:
|
||||||
|
grades_options += f'<option value="{g.pk}">{g.name}</option>'
|
||||||
|
except Exception:
|
||||||
|
grades_options = '<option value="">All grades</option>'
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<div class="user-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 users by name, username or email" />
|
||||||
|
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
|
||||||
|
{grades_options}
|
||||||
|
</select>
|
||||||
|
<!-- Help button for advanced search features -->
|
||||||
|
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="{results_id}" class="mt-2"></div>
|
||||||
|
|
||||||
|
<!-- Collapsible help panel (hidden by default) -->
|
||||||
|
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
|
||||||
|
<strong>Advanced search tips</strong>
|
||||||
|
<ul class="mb-0 mt-1">
|
||||||
|
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
|
||||||
|
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
|
||||||
|
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
|
||||||
|
<ul class="mb-0 mt-1">
|
||||||
|
<li><code>name:</code> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
|
||||||
|
<li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
|
||||||
|
<li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
|
||||||
|
<li><code>email:</code> — matches email address.</li>
|
||||||
|
<li><code>username:</code> — matches username.</li>
|
||||||
|
<li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
|
||||||
|
<li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
|
||||||
|
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
|
||||||
|
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="form-label small">Selected users</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}');
|
||||||
|
const gradeSelect = document.getElementById('grade_filter_{self.name}');
|
||||||
|
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
|
||||||
|
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
|
||||||
|
|
||||||
|
let timeout = null;
|
||||||
|
function fetchResults(q) {{
|
||||||
|
const grade = gradeSelect ? gradeSelect.value : '';
|
||||||
|
if (!q || q.trim().length === 0) {{
|
||||||
|
results.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
|
||||||
|
fetch(url)
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(html => {{ results.innerHTML = html; }});
|
||||||
|
}}
|
||||||
|
|
||||||
|
search.addEventListener('keyup', function(e) {{
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => fetchResults(search.value), 300);
|
||||||
|
}});
|
||||||
|
|
||||||
|
gradeSelect && gradeSelect.addEventListener('change', function(e) {{
|
||||||
|
// re-run search with same query when grade changes
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => fetchResults(search.value), 50);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Toggle help panel visibility
|
||||||
|
if (helpBtn && helpPanel) {{
|
||||||
|
helpBtn.addEventListener('click', function(e) {{
|
||||||
|
e.preventDefault();
|
||||||
|
helpPanel.classList.toggle('d-none');
|
||||||
|
const expanded = !helpPanel.classList.contains('d-none');
|
||||||
|
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
// fieldName for this widget instance
|
||||||
|
const widgetFieldName = '{self.name}';
|
||||||
|
|
||||||
|
// Add button click delegation: results list buttons have data attributes
|
||||||
|
results.addEventListener('click', function(e) {{
|
||||||
|
const btn = e.target.closest('.user-add-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const id = btn.getAttribute('data-user-id');
|
||||||
|
const text = btn.getAttribute('data-user-text') || btn.textContent.trim();
|
||||||
|
const grade = btn.getAttribute('data-user-grade') || '';
|
||||||
|
addUserToField(widgetFieldName, id, text, grade);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Wire up the "Add all results" button if present
|
||||||
|
const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
|
||||||
|
if (addAllBtn) {{
|
||||||
|
addAllBtn.addEventListener('click', function(e) {{
|
||||||
|
e.preventDefault();
|
||||||
|
addAllFromResults(widgetFieldName);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.addUserToField = function(fieldName, id, text, grade) {{
|
||||||
|
const sel = document.getElementById('id_' + fieldName);
|
||||||
|
if (!sel) return;
|
||||||
|
// prevent duplicate
|
||||||
|
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);
|
||||||
|
|
||||||
|
// add to visible list (include grade if provided)
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
li.id = 'selected_user_' + fieldName + '_' + id;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.innerHTML = text + (grade ? ' <small class="text-muted ms-2">' + grade + '</small>' : '');
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.removeUserFromField = 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_user_' + fieldName + '_' + id);
|
||||||
|
if (li && li.parentNode) li.parentNode.removeChild(li);
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.addAllFromResults = function(fieldName) {{
|
||||||
|
const list = document.getElementById('user_search_results_list_' + fieldName);
|
||||||
|
if (!list) return;
|
||||||
|
const items = list.querySelectorAll('li[data-user-id]');
|
||||||
|
items.forEach(function(it) {{
|
||||||
|
const id = it.getAttribute('data-user-id');
|
||||||
|
const text = it.getAttribute('data-user-text') || it.textContent.trim();
|
||||||
|
const grade = it.getAttribute('data-user-grade') || '';
|
||||||
|
addUserToField(fieldName, id, text, grade);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
}})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return mark_safe(html)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSearchWidget:
|
||||||
|
"""Django-widget-compatible lightweight wrapper around
|
||||||
|
`UserSearchSelectMultipleWidget` so it can be reused as a field widget.
|
||||||
|
|
||||||
|
Implements the small subset of the Widget API that Django's forms
|
||||||
|
and templates expect: `render`, `value_from_datadict`, `id_for_label`,
|
||||||
|
`use_required_attribute`, and a couple of simple attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
is_hidden = False
|
||||||
|
needs_multipart_form = False
|
||||||
|
use_fieldset = False
|
||||||
|
|
||||||
|
def __init__(self, field=None):
|
||||||
|
# keep a reference to the original field if available (used for
|
||||||
|
# delegation such as `use_required_attribute`).
|
||||||
|
self.field = field
|
||||||
|
self.attrs = {}
|
||||||
|
|
||||||
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
|
self.attrs = attrs or {}
|
||||||
|
value = value or []
|
||||||
|
widget = UserSearchSelectMultipleWidget(name, value)
|
||||||
|
return widget.render()
|
||||||
|
|
||||||
|
def value_from_datadict(self, data, files, name):
|
||||||
|
# Most form backends provide getlist
|
||||||
|
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
|
||||||
+15
-1
@@ -31,6 +31,8 @@ DEBUG = int(os.environ.get("DEBUG", default=0))
|
|||||||
|
|
||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
"localhost",
|
"localhost",
|
||||||
|
"localhost:8000",
|
||||||
|
"localhost:8080",
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"161.35.163.87",
|
"161.35.163.87",
|
||||||
"46.101.13.46",
|
"46.101.13.46",
|
||||||
@@ -241,7 +243,7 @@ MEDIA_ROOT = "media/"
|
|||||||
LOGIN_REDIRECT_URL = "/"
|
LOGIN_REDIRECT_URL = "/"
|
||||||
LOGOUT_REDIRECT_URL = "/"
|
LOGOUT_REDIRECT_URL = "/"
|
||||||
|
|
||||||
INTERNAL_IPS = ["localhost", "127.0.0.1"]
|
INTERNAL_IPS = ["localhost", "127.0.0.1", "localhost:8000", "localhost:8080"]
|
||||||
|
|
||||||
#LOGGING = {
|
#LOGGING = {
|
||||||
# "version": 1,
|
# "version": 1,
|
||||||
@@ -255,6 +257,18 @@ INTERNAL_IPS = ["localhost", "127.0.0.1"]
|
|||||||
|
|
||||||
CORS_ORIGIN_ALLOW_ALL = True
|
CORS_ORIGIN_ALLOW_ALL = True
|
||||||
|
|
||||||
|
# Development: trust local origins (including ports) so the dev nginx reverse
|
||||||
|
# proxy and the Django runserver can POST/PUT without CSRF Origin errors.
|
||||||
|
if DEBUG:
|
||||||
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
"http://localhost",
|
||||||
|
"http://127.0.0.1",
|
||||||
|
"http://localhost:8000",
|
||||||
|
"http://127.0.0.1:8000",
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
]
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
|
||||||
|
|||||||
Reference in New Issue
Block a user