Implement user search functionality and enhance supervisor management UI

This commit is contained in:
Ross
2026-06-15 10:28:32 +01:00
parent fc9f2ed6af
commit 35ab7443d2
8 changed files with 268 additions and 44 deletions
+10
View File
@@ -144,6 +144,16 @@ class SupervisorFilter(django_filters.FilterSet):
"site": ["exact"], "site": ["exact"],
} }
@property
def qs(self):
parent = super().qs
if "active" not in self.request.GET:
return parent.filter(active=True)
return parent
def __init__( def __init__(
self, self,
data=None, data=None,
+17 -4
View File
@@ -1,10 +1,23 @@
{% extends 'generic/base.html' %} {% extends 'generic/base.html' %}
{% block navigation %} {% block navigation %}
{{block.super}} {{ block.super }}
<br/> <div class="container-fluid px-0 mb-3 mt-1">
Supervisors: <a href="{% url 'generic:supervisor' %}">View all</a> <div class="d-flex justify-content-between align-items-center bg-dark bg-opacity-50 border border-secondary rounded p-2">
<a href="{% url 'generic:supervisor_create' %}">Create new</a> <div class="d-flex align-items-center gap-2">
<span class="text-muted small fw-semibold text-uppercase me-2"><i class="bi bi-person-workspace me-1"></i>Supervisors:</span>
<a href="{% url 'generic:supervisor' %}" class="btn btn-sm btn-outline-info {% if request.resolver_match.url_name == 'supervisor' and 'active' not in request.GET %}active{% endif %}">
<i class="bi bi-person-check-fill me-1"></i>View Active
</a>
<a href="{% url 'generic:supervisor' %}?active=" class="btn btn-sm btn-outline-secondary {% if request.resolver_match.url_name == 'supervisor' and 'active' in request.GET %}active{% endif %}">
<i class="bi bi-people-fill me-1"></i>View All
</a>
<a href="{% url 'generic:supervisor_create' %}" class="btn btn-sm btn-outline-success {% if request.resolver_match.url_name == 'supervisor_create' %}active{% endif %}">
<i class="bi bi-plus-circle-fill me-1"></i>Create New
</a>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block js %} {% block js %}
+35 -27
View File
@@ -1,34 +1,42 @@
{% extends "generic/supervisor_base.html" %} {% extends "generic/supervisor_base.html" %}
<!-- {% load static from static %} -->
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block css %}
<style>
</style>
{% endblock %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
{{form.media}}
<script type="text/javascript">
</script>
<!-- {{ form.media }} -->
{% endblock %}
{% block content %} {% block content %}
<h2>Edit Supervisor / {{object.cid}}</h2> <div class="row justify-content-center mt-3">
<p>Use this form to create / edit a supervisor</p> <div class="col-12 col-md-8 col-lg-6">
<p>Trainees can be assigned to a supervisor by editing the trainee and selecting the supervisor from the drop down list.</p> <div class="card border-secondary bg-dark text-white shadow-sm mb-4">
{% if errors %} <div class="card-body p-4">
<div class="alert alert-info" role="alert">{{errors}}</a></div> <h2 class="card-title mb-3">
{% endif %} {% if object %}
<form action="" method="post" enctype="multipart/form-data" id="condition-form"> Edit Supervisor
{% csrf_token %} {% else %}
Create Supervisor
{% endif %}
</h2>
<p class="text-muted small mb-4">
Use this form to manage the supervisor account details. Trainees can be assigned to a supervisor by editing the trainee's profile.
</p>
<table> {% if errors %}
<div class="alert alert-danger border-danger bg-dark text-danger" role="alert">
{{ errors }}
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data" id="supervisor-form">
{% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
</table> <div class="mt-4 d-flex gap-2">
<input type="submit" class="submit-button" value="Submit" name="submit"> <button type="submit" class="btn btn-primary" name="submit">
</form> <i class="bi bi-check-circle me-1"></i>Save changes
</button>
<a href="{% url 'generic:supervisor' %}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
+34 -11
View File
@@ -1,24 +1,47 @@
{% extends 'generic/supervisor_base.html' %} {% extends 'generic/supervisor_base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content %} {% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4 mt-2">
<div>
<h2 class="mb-1">Supervisors</h2>
<p class="text-muted mb-0">
Manage registered course and educational supervisors, hospital sites, and trainees.
</p>
</div>
<div class="text-md-end">
<span class="badge bg-secondary rounded-pill px-3 py-2">
Showing: {{ table.rows|length }}
</span>
</div>
</div>
<h2>Supervisors</h2> <div class="card border-secondary bg-dark text-white shadow-sm mb-4">
<div class="card-body p-0">
<div id="table-div">
<span id="manage-span">
{% render_table table %} {% render_table table %}
</span>
</div>
</div>
</div>
{% comment %} <ul id="supervisor-list">
{% for supervisor in object_list %}
<li class="supervisor"><a href="{% url 'generic:supervisor_detail' pk=supervisor.pk %}">{{supervisor.name}}</a>
<br/>{{supervisor.email}} [{{supervisor.site}}]
</li>
{% endfor %}
</ul> {% endcomment %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %} {% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
{% endblock %} {% endblock %}
{% block js %} {% block js %}
<style> <style>
td, th { padding-left: 10px } .table {
margin-bottom: 0 !important;
}
.table th, .table td {
padding: 0.75rem 1rem !important;
border-color: rgba(255, 255, 255, 0.08) !important;
}
.table thead th {
background-color: rgba(0, 0, 0, 0.2) !important;
color: #d3e9ff !important;
border-bottom: 2px solid rgba(255, 255, 255, 0.15) !important;
}
</style> </style>
{% endblock %} {% endblock %}
+43
View File
@@ -140,3 +140,46 @@ def test_cid_manager_can_send_password_reset_from_account_profile(client, create
assert mail.outbox[0].to == ["managed@example.com"] assert mail.outbox[0].to == ["managed@example.com"]
@pytest.mark.django_db
def test_people_page_and_user_search(client, create_users, create_cid_manager):
# Log in as cid_user_manager
client.force_login(create_cid_manager)
# 1. Test standard GET request to /people page
response = client.get(reverse("people"))
assert response.status_code == 200
assert b"Quick User Search" in response.content
assert b"hx-indicator=\"#search-indicator\"" in response.content
# 2. Test HTMX GET request with empty query
response_empty = client.get(
reverse("people"),
{"q": ""},
HTTP_HX_REQUEST="true",
)
assert response_empty.status_code == 200
assert response_empty.content == b""
# 3. Test HTMX GET request with matching query
user1, user2 = create_users
response_search = client.get(
reverse("people"),
{"q": "user1"},
HTTP_HX_REQUEST="true",
)
assert response_search.status_code == 200
assert b"user1" in response_search.content
assert b"user1@user.com" in response_search.content
assert b"Actions" in response_search.content
# 4. Test HTMX GET request with no matching users
response_no_match = client.get(
reverse("people"),
{"q": "nonexistent_username_xyz"},
HTTP_HX_REQUEST="true",
)
assert response_no_match.status_code == 200
assert b"No users found matching" in response_no_match.content
+31
View File
@@ -140,9 +140,40 @@ def server(request):
@user_is_cid_user_manager @user_is_cid_user_manager
def people(request): def people(request):
"""Render the people management overview or search users via HTMX.
Scope: managers and superusers.
Functionality: provides details on user roles/groups, and a single search
input that filters users dynamically via HTMX GET requests.
"""
if request.htmx:
q = request.GET.get("q", "").strip()
if not q:
return HttpResponse("")
users = (
User.objects.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(username__icontains=q)
)
.prefetch_related(
"userprofile", "userprofile__grade", "userprofile__supervisor", "user_groups"
)
.order_by("first_name", "last_name", "username")[:20]
)
return render(
request,
"rad/partials/_people_search_results.html",
{"users": users, "q": q},
)
return render(request, "people.html", {}) return render(request, "people.html", {})
@login_required @login_required
def profile(request): def profile(request):
"""Render the current user's profile page with group management context. """Render the current user's profile page with group management context.
+25
View File
@@ -13,6 +13,31 @@ Trainees can be viewed <a href='{% url "trainees" %}'>here</a> (
{% endfor %} {% endfor %}
) )
<div class="card border-secondary bg-dark text-white mb-4 mt-3">
<div class="card-body">
<h4 class="card-title mb-3"><i class="bi bi-search me-2"></i>Quick User Search</h4>
<div class="row align-items-center g-2">
<div class="col-12 col-md-8">
<div class="input-group">
<span class="input-group-text bg-secondary border-secondary text-white"><i class="bi bi-search"></i></span>
<input type="search"
name="q"
class="form-control bg-dark text-white border-secondary"
placeholder="Search users by name, email, or username..."
hx-get="{% url 'people' %}"
hx-trigger="input delay:300ms, search"
hx-target="#search-results"
hx-indicator="#search-indicator">
</div>
</div>
<div class="col-auto">
<span id="search-indicator" class="spinner-border spinner-border-sm text-light htmx-indicator" role="status" aria-hidden="true"></span>
</div>
</div>
<div id="search-results" class="mt-3"></div>
</div>
</div>
<h2>People</h2> <h2>People</h2>
<p>The system contains a number of different people / groups. This affects how they are managed and what they are able to do.</p> <p>The system contains a number of different people / groups. This affects how they are managed and what they are able to do.</p>
<p>They can be managed using the navigation links above</p> <p>They can be managed using the navigation links above</p>
@@ -0,0 +1,71 @@
{% if users %}
<div class="table-responsive">
<table class="table table-hover table-striped align-middle border-secondary text-white">
<thead class="table-dark">
<tr>
<th>User</th>
<th>Grade</th>
<th>Email</th>
<th>Supervisor</th>
<th>Groups</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<div class="fw-bold">
{{ user.first_name }} {{ user.last_name }}
<a href="{% url 'account_profile' user.username %}" class="text-info ms-1" title="View Profile">
<i class="bi bi-person-bounding-box"></i>
</a>
</div>
<small class="text-muted">{{ user.username }}</small>
</td>
<td>
{% if user.userprofile.grade %}
<span class="badge bg-secondary">{{ user.userprofile.grade }}</span>
{% else %}
<span class="text-muted small">No grade</span>
{% endif %}
</td>
<td>
<small>{{ user.email|default:"—" }}</small>
</td>
<td>
{% if user.userprofile.supervisor %}
<div>
{{ user.userprofile.supervisor }}
<a href="{% url 'generic:supervisor_detail' user.userprofile.supervisor.pk %}" class="text-info ms-1" title="View Supervisor">
<i class="bi bi-link-45deg"></i>
</a>
</div>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td>
{% for group in user.user_groups.all %}
<span class="badge bg-info text-dark me-1">{{ group.name }}</span>
{% empty %}
<span class="text-muted small">None</span>
{% endfor %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{% url 'account_update' user.username %}?redirect={{ request.path }}" class="btn btn-outline-light">Edit User</a>
<a href="{% url 'account_profile_update' user.username %}?redirect={{ request.path }}" class="btn btn-outline-info">Edit Profile</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="text-muted small mt-2">Showing up to 20 results for "{{ q }}".</div>
{% else %}
<div class="alert alert-warning border-warning bg-dark text-warning my-2">
<i class="bi bi-exclamation-triangle-fill me-2"></i>No users found matching "{{ q }}".
</div>
{% endif %}