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"],
|
"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,
|
||||||
|
|||||||
@@ -2,9 +2,22 @@
|
|||||||
|
|
||||||
{% 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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{% if object %}
|
||||||
|
Edit Supervisor
|
||||||
|
{% else %}
|
||||||
|
Create Supervisor
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="" method="post" enctype="multipart/form-data" id="condition-form">
|
</h2>
|
||||||
{% csrf_token %}
|
<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">
|
||||||
|
<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>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user