Implement user search functionality and enhance supervisor management UI
This commit is contained in:
@@ -144,6 +144,16 @@ class SupervisorFilter(django_filters.FilterSet):
|
||||
"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__(
|
||||
self,
|
||||
data=None,
|
||||
|
||||
@@ -2,9 +2,22 @@
|
||||
|
||||
{% block navigation %}
|
||||
{{ block.super }}
|
||||
<br/>
|
||||
Supervisors: <a href="{% url 'generic:supervisor' %}">View all</a>
|
||||
<a href="{% url 'generic:supervisor_create' %}">Create new</a>
|
||||
<div class="container-fluid px-0 mb-3 mt-1">
|
||||
<div class="d-flex justify-content-between align-items-center bg-dark bg-opacity-50 border border-secondary rounded p-2">
|
||||
<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 %}
|
||||
|
||||
{% block js %}
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
{% extends "generic/supervisor_base.html" %}
|
||||
<!-- {% load static from static %} -->
|
||||
{% 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 %}
|
||||
<h2>Edit Supervisor / {{object.cid}}</h2>
|
||||
<p>Use this form to create / edit a supervisor</p>
|
||||
<p>Trainees can be assigned to a supervisor by editing the trainee and selecting the supervisor from the drop down list.</p>
|
||||
{% if errors %}
|
||||
<div class="alert alert-info" role="alert">{{errors}}</a></div>
|
||||
<div class="row justify-content-center mt-3">
|
||||
<div class="col-12 col-md-8 col-lg-6">
|
||||
<div class="card border-secondary bg-dark text-white shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="card-title mb-3">
|
||||
{% if object %}
|
||||
Edit Supervisor
|
||||
{% else %}
|
||||
Create Supervisor
|
||||
{% endif %}
|
||||
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
||||
{% csrf_token %}
|
||||
</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 }}
|
||||
</table>
|
||||
<input type="submit" class="submit-button" value="Submit" name="submit">
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" name="submit">
|
||||
<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 %}
|
||||
@@ -1,24 +1,47 @@
|
||||
{% extends 'generic/supervisor_base.html' %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% 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 %}
|
||||
</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" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -140,9 +140,40 @@ def server(request):
|
||||
|
||||
@user_is_cid_user_manager
|
||||
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", {})
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
def profile(request):
|
||||
"""Render the current user's profile page with group management context.
|
||||
|
||||
@@ -13,6 +13,31 @@ Trainees can be viewed <a href='{% url "trainees" %}'>here</a> (
|
||||
{% 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>
|
||||
<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>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user