Compare commits

...
28 Commits
Author SHA1 Message Date
Ross d3669e8316 Uncomment table control creation logic to ensure selectable tables have row-selection controls 2025-11-03 15:54:15 +00:00
Ross b92a969a3b Refactor index and view templates to improve layout; remove unnecessary details tag for cleaner code 2025-11-03 15:36:15 +00:00
Ross c45d7a5464 Update navbar dropdown toggle to maintain active state for improved user experience 2025-11-03 15:29:27 +00:00
Ross b08154f804 Refactor index.html layout for improved responsiveness and user navigation; update QuestionForm to use responsive columns for better display on small screens. 2025-11-03 13:47:03 +00:00
Ross 6d02b35f86 Add Crispy Forms integration to QuestionForm and update question_form template for improved layout and usability 2025-11-03 13:39:35 +00:00
Ross 3a5ceabff0 Refactor auth group dropdown in profile and fragment templates for improved functionality and user experience 2025-11-03 12:37:32 +00:00
Ross 3461e45f93 Refactor user group management by moving auth groups to profile.html and creating a dedicated auth_groups_fragment.html for better organization and clarity 2025-11-03 12:31:49 +00:00
Ross eec04e3197 Refactor user scores and index templates for improved layout and help information presentation 2025-11-03 12:27:20 +00:00
Ross 68dc7c05d8 Enhance user group management by adding support for Django auth groups and improving group toggle functionality for superusers and user managers 2025-11-03 12:25:29 +00:00
Ross ff02e3f02f Update profile link button style to use outline-light variant for better visibility 2025-11-03 12:23:25 +00:00
Ross 3db74607ce Enhance index.html with login prompts for unauthenticated users and additional help information for external users 2025-11-03 12:20:25 +00:00
Ross 37eae568f0 Revamp index.html layout with improved user authentication display and enhanced profile information section 2025-11-03 12:13:19 +00:00
Ross 772e852763 Implement user group management with toggle functionality for superusers 2025-11-03 12:10:58 +00:00
Ross d3a39d11a5 Add permission groups display and admin edit link to user profile 2025-11-03 12:04:06 +00:00
Ross 9cc4ea3cb0 Revamp user profile layout with enhanced styling and improved group and exam display 2025-11-03 11:58:57 +00:00
Ross d93483db26 Refactor permission checks in UpdateQuestionMixin to include author and reviewer group validation 2025-11-03 11:53:45 +00:00
Ross aff8d205ed Remove filter options section from user view template and update permission checks in toggle function 2025-11-03 11:44:14 +00:00
Ross e58f070ae2 Enhance question review functionality with latest review status annotation and display in templates 2025-11-03 11:37:09 +00:00
Ross 7a00efdf39 Add filtering option for questions reviewed by the current user in question review list 2025-11-03 11:21:17 +00:00
Ross e173427d84 Fix HTML escaping for question titles in question review list 2025-11-03 11:11:13 +00:00
Ross 3c1bd40ce6 Add user and supervisor search endpoints for trainees bulk-update UI 2025-11-03 11:08:02 +00:00
Ross 0679a0ec13 Refactor bulk delete error handling and update user search visibility for superusers 2025-11-03 10:59:18 +00:00
Ross 5d91b4a4ff Adjust padding in floating filter for improved layout consistency 2025-11-03 10:53:18 +00:00
Ross 83a60203f7 Enhance filtering functionality and UI consistency across user-related views by implementing a generic search filter and updating filter display in templates. 2025-11-03 10:47:40 +00:00
Ross 2d0098b2b6 Merge branch 'master' of https://git.xkjq.uk/ross/penracourses 2025-11-03 10:38:27 +00:00
Ross 66f9dadcc8 Normalize supervisor emails and add unique constraint for case-insensitive email uniqueness 2025-11-03 10:30:45 +00:00
Ross 7d079e0466 Update Dockerfile to use Python 3.14 and fix docker-compose volume definitions 2025-11-03 10:25:20 +00:00
Ross 3ea384542d Enhance user list handling by adding supervisor email input and updating JSON submission logic 2025-11-03 09:46:24 +00:00
31 changed files with 1099 additions and 353 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# pull official base image # pull official base image
FROM python:3.12.1-slim FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# set work directory # set work directory
+41 -6
View File
@@ -1,14 +1,49 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% block content %} {% block content %}
<h2>Atlas</h2> <div class="container my-4">
<div class="row">
<div class="col-12">
<h2 class="mb-3">Atlas</h2>
</div>
</div>
<a href="{% url 'atlas:user_collections' %}">Collections to view / take</a><br/> <div class="row">
<div class="col-12 col-md-8">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Collections</h5>
<p class="card-text">Quick access to collections you can view or take.</p>
<a href="{% url 'atlas:user_collections' %}" class="btn btn-primary">Collections to view / take</a>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title">My cases / collections</h5>
<p>View my <a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">cases</a>.</p>
<p>
<a href="{% url 'atlas:user_uploads' %}" class="btn btn-outline-secondary">
<i class="bi bi-cloud-arrow-up me-1"></i> Uploads awaiting import
</a>
</p>
</div>
</div>
</div>
<h3>My cases / collections</h3> <div class="col-12 col-md-4">
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.<br/> <div class="card">
<div class="card-body">
<a href="{% url 'atlas:user_uploads' %}"><i class="bi bi-cloud-arrow-up"></i> Uploads awaiting import</a> <h6 class="card-subtitle mb-2 text-muted">Quick links</h6>
<ul class="list-unstyled mb-0">
<li><a href="{% url 'atlas:user_collections' %}">Collections to view / take</a></li>
<li><a href="{% url 'atlas:case_view' %}?author={{ request.user.id }}">My cases</a></li>
<li><a href="{% url 'atlas:user_uploads' %}">Uploads awaiting import</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
+3 -2
View File
@@ -8,7 +8,7 @@
<div class="container-fluid"> <div class="container-fluid">
<h2>My Collections</h2> <h2>My Collections</h2>
<details> {% comment %} <details>
<summary> <summary>
<h4>Filter</h4> <h4>Filter</h4>
</summary> </summary>
@@ -18,9 +18,10 @@
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" /> <input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
</form> </form>
</div> </div>
</details> {% endcomment %}
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>. View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</details>
{% render_table table %} {% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div> </div>
<div id="exam-options"></div> <div id="exam-options"></div>
+3 -1
View File
@@ -54,4 +54,6 @@ services:
volumes: volumes:
postgres_data: postgres_data:
pgadmin_data: name: docker_postgres_data
pgadmin_data:
name: docker_pgadmin_data
+45
View File
@@ -1,4 +1,6 @@
import django_filters import django_filters
from django.db.models import Q
from collections import OrderedDict
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -34,6 +36,8 @@ class CidUserFilter(django_filters.FilterSet):
return parent return parent
class UserUserFilter(django_filters.FilterSet): class UserUserFilter(django_filters.FilterSet):
# Generic search across name and email/username
q = django_filters.CharFilter(method='filter_search', label='Search')
userprofile__grade = django_filters.ModelChoiceFilter(queryset=UserGrades.objects.all(),label="Grade") userprofile__grade = django_filters.ModelChoiceFilter(queryset=UserGrades.objects.all(),label="Grade")
userprofile__supervisor = django_filters.ModelChoiceFilter(queryset=Supervisor.objects.all(), label="Supervisor") userprofile__supervisor = django_filters.ModelChoiceFilter(queryset=Supervisor.objects.all(), label="Supervisor")
userprofile__peninsula_trainee = django_filters.BooleanFilter(label="Peninsula Trainee") userprofile__peninsula_trainee = django_filters.BooleanFilter(label="Peninsula Trainee")
@@ -69,6 +73,47 @@ class UserUserFilter(django_filters.FilterSet):
super(UserUserFilter, self).__init__(*args, **kwargs) super(UserUserFilter, self).__init__(*args, **kwargs)
self.form.initial['is_active'] = True self.form.initial['is_active'] = True
def filter_search(self, queryset, name, value):
"""
Generic search that matches first name, last name, email or username.
"""
v = (value or "").strip()
if not v:
return queryset
return queryset.filter(
Q(first_name__icontains=v)
| Q(last_name__icontains=v)
| Q(email__icontains=v)
| Q(username__icontains=v)
)
def __post_init_reorder__(self):
"""Internal helper to move the generic search 'q' to the front of the form fields."""
try:
fields = self.form.fields
if 'q' in fields:
new_order = ['q'] + [k for k in fields.keys() if k != 'q']
self.form.fields = OrderedDict((k, fields[k]) for k in new_order)
except Exception:
pass
# Ensure reorder runs after initialization
def __new__(cls, *args, **kwargs):
instance = super(UserUserFilter, cls).__new__(cls)
# attach a post-init hook to reorder form fields
orig_init = instance.__init__
def wrapped_init(*a, **k):
orig_init(*a, **k)
try:
instance.__post_init_reorder__()
except Exception:
pass
instance.__init__ = wrapped_init
return instance
class ExaminationFilter(django_filters.FilterSet): class ExaminationFilter(django_filters.FilterSet):
class Meta: class Meta:
@@ -0,0 +1,51 @@
from django.db import migrations
# no additional imports required
def normalize_and_dedupe(apps, schema_editor):
Supervisor = apps.get_model('generic', 'Supervisor')
UserProfile = apps.get_model('generic', 'UserProfile')
CidUser = apps.get_model('generic', 'CidUser')
# Build groups by normalized email without saving changes yet to avoid unique constraint errors
supervisors = list(Supervisor.objects.all())
groups = {}
for s in supervisors:
norm_email = (s.email or "").strip().lower()
norm_name = (s.name or "").strip()
groups.setdefault(norm_email, []).append((s, norm_name))
# For each group, pick a keeper (smallest PK), reassign FKs from duplicates to keeper, delete duplicates,
# then update the keeper's normalized email/name.
for norm_email, entries in groups.items():
# pick keeper by lowest id
entries_sorted = sorted(entries, key=lambda e: e[0].pk)
keeper_obj, keeper_name = entries_sorted[0]
# reassign duplicates
for dup_obj, dup_name in entries_sorted[1:]:
UserProfile.objects.filter(supervisor_id=dup_obj.pk).update(supervisor_id=keeper_obj.pk)
CidUser.objects.filter(supervisor_id=dup_obj.pk).update(supervisor_id=keeper_obj.pk)
dup_obj.delete()
# update keeper's email and name to normalized values if needed
changed = False
if norm_email and keeper_obj.email != norm_email:
keeper_obj.email = norm_email
changed = True
if keeper_name and keeper_obj.name != keeper_name:
keeper_obj.name = keeper_name
changed = True
if changed:
keeper_obj.save()
class Migration(migrations.Migration):
dependencies = [
('generic', '0027_alter_questionreview_status'),
]
operations = [
migrations.RunPython(normalize_and_dedupe, reverse_code=migrations.RunPython.noop),
]
@@ -0,0 +1,17 @@
from django.db import migrations
from django.db.models import UniqueConstraint
from django.db.models.functions import Lower
class Migration(migrations.Migration):
dependencies = [
('generic', '0028_normalize_supervisor_emails'),
]
operations = [
migrations.AddConstraint(
model_name='supervisor',
constraint=UniqueConstraint(Lower('email'), name='unique_supervisor_email_ci'),
),
]
+12 -2
View File
@@ -5,6 +5,8 @@ import os
from typing import Optional, Self, Tuple from typing import Optional, Self, Tuple
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import models from django.db import models
from django.db.models import UniqueConstraint
from django.db.models.functions import Lower
from django.forms import ValidationError from django.forms import ValidationError
from django.http import Http404, HttpRequest from django.http import Http404, HttpRequest
from django.shortcuts import render from django.shortcuts import render
@@ -1859,8 +1861,16 @@ class Supervisor(models.Model):
return reverse("generic:supervisor_detail", kwargs={"pk": self.pk}) return reverse("generic:supervisor_detail", kwargs={"pk": self.pk})
def clean(self): def clean(self):
self.email = self.email.strip() if self.email:
self.name = self.name.strip() # normalize email: strip and lowercase for consistency
self.email = self.email.strip().lower()
if self.name:
self.name = self.name.strip()
class Meta:
constraints = [
UniqueConstraint(Lower('email'), name='unique_supervisor_email_ci')
]
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
+15 -15
View File
@@ -151,7 +151,7 @@ class FirstImageColumn(tables.Column):
return format_html('<span title="{}">Invalid image url<span>', image_object) return format_html('<span title="{}">Invalid image url<span>', image_object)
class CidUserTable(tables.Table): class CidUserTable(SelectionTable):
cid = tables.LinkColumn( cid = tables.LinkColumn(
"cid_scores", args=[A("cid"), A("passcode")], orderable=True "cid_scores", args=[A("cid"), A("passcode")], orderable=True
) )
@@ -163,7 +163,7 @@ class CidUserTable(tables.Table):
# anatomy_exams = tables.ManyToManyColumn(verbose_name="Anatomy Exams") # anatomy_exams = tables.ManyToManyColumn(verbose_name="Anatomy Exams")
group = tables.Column(linkify=True) group = tables.Column(linkify=True)
selection = tables.CheckBoxColumn(accessor="pk", orderable=False) #selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
emails = tables.Column(empty_values=(), orderable=False) emails = tables.Column(empty_values=(), orderable=False)
@@ -171,7 +171,7 @@ class CidUserTable(tables.Table):
"generic:update_cid", text="Edit", args=[A("pk")], orderable=True "generic:update_cid", text="Edit", args=[A("pk")], orderable=True
) )
class Meta: class Meta(SelectionTable.Meta):
model = CidUser model = CidUser
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ( fields = (
@@ -199,7 +199,7 @@ class CidUserTable(tables.Table):
return format_html(record.email) return format_html(record.email)
class CidUserExamTable(tables.Table): class CidUserExamTable(SelectionTable):
cid = tables.LinkColumn( cid = tables.LinkColumn(
"cid_scores", args=[A("cid"), A("passcode")], orderable=True "cid_scores", args=[A("cid"), A("passcode")], orderable=True
) )
@@ -210,7 +210,7 @@ class CidUserExamTable(tables.Table):
longs_exams = tables.ManyToManyColumn(verbose_name="Long Exams") longs_exams = tables.ManyToManyColumn(verbose_name="Long Exams")
anatomy_exams = tables.ManyToManyColumn(verbose_name="Anatomy Exams") anatomy_exams = tables.ManyToManyColumn(verbose_name="Anatomy Exams")
selection = tables.CheckBoxColumn(accessor="pk", orderable=False) #selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
emails = tables.Column(empty_values=(), orderable=False) emails = tables.Column(empty_values=(), orderable=False)
@@ -218,7 +218,7 @@ class CidUserExamTable(tables.Table):
"generic:update_cid", text="Edit", args=[A("pk")], orderable=True "generic:update_cid", text="Edit", args=[A("pk")], orderable=True
) )
class Meta: class Meta(SelectionTable.Meta):
model = CidUser model = CidUser
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ( fields = (
@@ -256,7 +256,7 @@ class CidUserExamTable(tables.Table):
# return format_html("""<a href="#" onclick="return window.create_popup_window('/longs/series/{}', 'Series')" >Popup</a>""", record.pk) # return format_html("""<a href="#" onclick="return window.create_popup_window('/longs/series/{}', 'Series')" >Popup</a>""", record.pk)
class UserUserTable(tables.Table): class UserUserTable(SelectionTable):
user = tables.Column( user = tables.Column(
linkify={"viewname": "account_profile", "args": [A("username")]}, linkify={"viewname": "account_profile", "args": [A("username")]},
@@ -275,7 +275,7 @@ class UserUserTable(tables.Table):
accessor=A("userprofile__supervisor"), accessor=A("userprofile__supervisor"),
) )
selection = tables.CheckBoxColumn(accessor="pk", orderable=False) #selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
edit = tables.Column( edit = tables.Column(
empty_values=(), empty_values=(),
@@ -294,7 +294,7 @@ class UserUserTable(tables.Table):
date_joined = tables.DateTimeColumn(format="Y/m/d") date_joined = tables.DateTimeColumn(format="Y/m/d")
class Meta: class Meta(SelectionTable.Meta):
model = User model = User
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
fields = ( fields = (
@@ -348,7 +348,7 @@ class UserUserTable(tables.Table):
record.email, record.email,
) )
class ExaminationTable(tables.Table): class ExaminationTable(SelectionTable):
examination = tables.Column( examination = tables.Column(
linkify=("generic:examination_detail", {"pk": tables.A("pk")}), linkify=("generic:examination_detail", {"pk": tables.A("pk")}),
verbose_name="Condition", verbose_name="Condition",
@@ -360,19 +360,19 @@ class ExaminationTable(tables.Table):
delete = tables.LinkColumn( delete = tables.LinkColumn(
"generic:examination_delete", text="Delete", args=[A("pk")], orderable=False "generic:examination_delete", text="Delete", args=[A("pk")], orderable=False
) )
selection = tables.CheckBoxColumn(accessor="pk", orderable=False) #selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
# synonym = tables.ManyToManyColumn(verbose_name="Synonyms") # synonym = tables.ManyToManyColumn(verbose_name="Synonyms")
#synonym = tables.Column(empty_values=()) #synonym = tables.Column(empty_values=())
#parent = tables.ManyToManyColumn(verbose_name="Parents") #parent = tables.ManyToManyColumn(verbose_name="Parents")
class Meta: class Meta(SelectionTable.Meta):
model = Examination model = Examination
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
#fields = ("primary", "subspecialty") #fields = ("primary", "subspecialty")
sequence = ("examination",) sequence = ("examination",)
class SupervisorTable(tables.Table): class SupervisorTable(SelectionTable):
name = tables.Column( name = tables.Column(
linkify=("generic:supervisor_detail", {"pk": tables.A("pk")}), linkify=("generic:supervisor_detail", {"pk": tables.A("pk")}),
verbose_name="Name", verbose_name="Name",
@@ -383,13 +383,13 @@ class SupervisorTable(tables.Table):
accessor="pk", orderable=False accessor="pk", orderable=False
) )
class Meta: class Meta(SelectionTable.Meta):
model = Supervisor model = Supervisor
template_name = "django_tables2/bootstrap4.html" template_name = "django_tables2/bootstrap4.html"
sequence = ("name", "email") sequence = ("name", "email")
exclude = ("id",) exclude = ("id",)
attrs = {"id": "supervisor-table"} SelectionTable.Meta.attrs.update({"id": "supervisor-table"})
def render_edit(self, value, record): def render_edit(self, value, record):
url = f"{reverse("generic:supervisor_edit", args=[record.pk])}?redirect={self.request.path}" url = f"{reverse("generic:supervisor_edit", args=[record.pk])}?redirect={self.request.path}"
+1 -12
View File
@@ -7,23 +7,12 @@
{% block content %} {% block content %}
<div id="view-filter-options">
<details class="filter">
<summary>
<h3>Filter CID Users </h3>
</summary>
<form action="" method="get">
{{ filter.form |crispy}}
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
</form>
</details>
</div>
<div> <div>
<span id="manage-span"> <span id="manage-span">
{% render_table table %} {% render_table table %}
</span> </span>
</div> </div>
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
<details> <details>
<summary> <summary>
@@ -0,0 +1,54 @@
{% comment %}Auth groups fragment for HTMX replacement{% endcomment %}
<div id="auth-groups-{{ user.pk }}" class="mt-1 d-flex flex-wrap gap-2 align-items-center">
{% if user.groups.all %}
{% for perm_group in user.groups.all %}
{% if request.user.is_superuser or is_cid_user_manager %}
<button
class="btn btn-sm btn-info text-dark"
hx-post="{% url 'generic:user_toggle_group' user.pk perm_group.pk %}?type=auth"
hx-target="#auth-groups-{{ user.pk }}"
hx-swap="outerHTML"
>
{{ perm_group.name }}
<span class="ms-1 text-muted small">&times;</span>
</button>
{% else %}
<span class="badge bg-info text-dark me-1">{{ perm_group.name }}</span>
{% endif %}
{% endfor %}
{% else %}
<span class="badge bg-light text-muted">No permission groups</span>
{% endif %}
{% if request.user.is_superuser or is_cid_user_manager %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" id="addAuthGroupDropdown" data-bs-toggle="dropdown" aria-expanded="false">
Add
</button>
<ul class="dropdown-menu" aria-labelledby="addAuthGroupDropdown">
{% if available_auth_groups.count %}
{% for ag in available_auth_groups %}
<li>
<button
class="dropdown-item"
hx-post="{% url 'generic:user_toggle_group' user.pk ag.pk %}?type=auth"
hx-target="#auth-groups-{{ user.pk }}"
hx-swap="outerHTML"
>
{{ ag.name }}
</button>
</li>
{% endfor %}
{% else %}
{% if request.user.is_superuser %}
<li>
<a class="dropdown-item" href="{% url 'admin:auth_group_add' %}" target="_blank" rel="noopener">Create permission group</a>
</li>
{% else %}
<li><span class="dropdown-item disabled">No groups available to add</span></li>
{% endif %}
{% endif %}
</ul>
</div>
{% endif %}
</div>
@@ -79,7 +79,7 @@
z-index: 1030; z-index: 1030;
background: var(--bs-body-bg); background: var(--bs-body-bg);
border-top: 1px solid rgba(0,0,0,0.08); border-top: 1px solid rgba(0,0,0,0.08);
padding: 0.5rem 0.75rem 1rem 0.75rem; padding: 0.5rem 0.75rem 0.5rem 0.75rem;
} }
.floating-filter .filter-body { .floating-filter .filter-body {
max-height: 50vh; max-height: 50vh;
@@ -0,0 +1,55 @@
{% load i18n %}
<div id="user-groups-{{ user.pk }}">
<div class="d-flex flex-column gap-2">
<div>
<small class="text-muted">Project groups:</small>
<div class="d-flex flex-wrap gap-2 mt-1">
{% if user.user_groups.all %}
{% for group in user.user_groups.all %}
{% if request.user.is_superuser or is_cid_user_manager %}
<button
class="btn btn-sm btn-outline-secondary"
hx-post="{% url 'generic:user_toggle_group' user.pk group.pk %}"
hx-target="#user-groups-{{ user.pk }}"
hx-swap="outerHTML"
>
{{ group.name }}
<span class="ms-1 text-muted small">&times;</span>
</button>
{% else %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'generic:user_group_detail' group.pk %}">{{ group.name }}</a>
{% endif %}
{% endfor %}
{% else %}
<div class="text-muted">No groups.</div>
{% endif %}
{% if request.user.is_superuser or is_cid_user_manager %}
{% if available_groups.count %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" id="addGroupDropdown" data-bs-toggle="dropdown" aria-expanded="false">
Add group
</button>
<ul class="dropdown-menu" aria-labelledby="addGroupDropdown">
{% for ag in available_groups %}
<li>
<button
class="dropdown-item"
hx-post="{% url 'generic:user_toggle_group' user.pk ag.pk %}"
hx-target="#user-groups-{{ user.pk }}"
hx-swap="outerHTML"
>
{{ ag.name }}
</button>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endif %}
</div>
</div>
{# Auth groups moved to the Permission groups area in profile.html #}
</div>
</div>
+1 -12
View File
@@ -4,18 +4,6 @@
<h2>Supervisors</h2> <h2>Supervisors</h2>
<details>
<summary>
<h4>Filter</h4>
</summary>
<div id="view-filter-options">
<form action="" method="get">
{{ filter.form }}
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
</form>
</div>
</details>
{% render_table table %} {% render_table table %}
{% comment %} <ul id="supervisor-list"> {% comment %} <ul id="supervisor-list">
@@ -25,6 +13,7 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> {% endcomment %} </ul> {% endcomment %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
{% endblock %} {% endblock %}
+1 -13
View File
@@ -8,19 +8,6 @@
{% block content %} {% block content %}
<a href='{% url "trainees" %}'>Trainees list</a> <a href='{% url "trainees" %}'>Trainees list</a>
<div id="view-filter-options">
<details class="filter">
<summary>
<h3>Filter Users </h3>
</summary>
<form action="" method="get">
{{ filter.form| crispy }}
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
</form>
</details>
</div>
<details class="bulk-edit"> <details class="bulk-edit">
<summary> <summary>
Bulk edit Bulk edit
@@ -71,6 +58,7 @@
{% render_table table %} {% render_table table %}
</span> </span>
</div> </div>
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
<a href="{% url 'accounts_bulk_create' %}">Bulk create users</a> <a href="{% url 'accounts_bulk_create' %}">Bulk create users</a>
<a href="{% url 'create_user' %}">Create single user</a> <a href="{% url 'create_user' %}">Create single user</a>
+5
View File
@@ -182,6 +182,11 @@ urlpatterns = [
views.UserUserGroupCreate.as_view(), views.UserUserGroupCreate.as_view(),
name="user_group_create", name="user_group_create",
), ),
path(
"user/<int:user_id>/toggle_group/<int:group_id>/",
views.user_toggle_group,
name="user_toggle_group",
),
path("cids/create", views.manage_cid_users, name="manage_cid_users"), path("cids/create", views.manage_cid_users, name="manage_cid_users"),
path("cids/<int:cid>/details", views.cid_details, name="cid_details"), path("cids/<int:cid>/details", views.cid_details, name="cid_details"),
path("cids/create/email", views.create_cid_email, name="create_cid_email"), path("cids/create/email", views.create_cid_email, name="create_cid_email"),
+215 -59
View File
@@ -5,7 +5,7 @@ import statistics
import threading import threading
from typing import Any from typing import Any
from dal import autocomplete from dal import autocomplete
from django.contrib.auth.models import User from django.contrib.auth.models import User, Group
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
from django.db.models.functions import Coalesce, Lower from django.db.models.functions import Coalesce, Lower
@@ -49,6 +49,7 @@ from reversion.views import RevisionMixin
from atlas.models import CaseCollection, CaseDetail from atlas.models import CaseCollection, CaseDetail
from generic.decorators import user_is_cid_user_manager from generic.decorators import user_is_cid_user_manager
from generic.filters import CidUserFilter, ExaminationFilter, SupervisorFilter from generic.filters import CidUserFilter, ExaminationFilter, SupervisorFilter
from generic.models import UserUserGroup
from generic.tables import ( from generic.tables import (
CidUserExamTable, CidUserExamTable,
@@ -212,7 +213,8 @@ def bulk_delete_questions(request):
Model = APP_MODEL_MAP.get(app) Model = APP_MODEL_MAP.get(app)
if Model is None: if Model is None:
return JsonResponse({"ok": False, "error": f"Unknown app: {app}"}, status=400) logger.error(f"bulk_delete_questions called with unknown app: {app}")
return HttpResponse(f"Unknown app: {app}")
deleted = 0 deleted = 0
errors = [] errors = []
@@ -222,7 +224,7 @@ def bulk_delete_questions(request):
qs.delete() qs.delete()
except Exception as e: except Exception as e:
logger.exception("bulk_delete failed for app=%s ids=%s", app, ids) logger.exception("bulk_delete failed for app=%s ids=%s", app, ids)
return JsonResponse({"ok": False, "error": str(e)}, status=500) return HttpResponse("Unable to deleted items")
# Render a small fragment summarising deletions # Render a small fragment summarising deletions
context = {"deleted": deleted, "errors": errors} context = {"deleted": deleted, "errors": errors}
@@ -3084,6 +3086,21 @@ class GenericViewBase:
# Build base queryset of questions for this app # Build base queryset of questions for this app
qs = self.question_object.objects.all().order_by("pk") qs = self.question_object.objects.all().order_by("pk")
# Optionally restrict iteration to questions reviewed by the current user
reviewed_filter = (request.POST.get("reviewed") or request.GET.get("reviewed")) == "me"
if reviewed_filter:
try:
from django.db.models import Exists, OuterRef
# use the module-level ContentType (imported near file top)
ct = ContentType.objects.get_for_model(self.question_object)
reviewed_exists = QuestionReview.objects.filter(
content_type=ct, object_id=OuterRef("pk"), author=request.user
)
qs = qs.annotate(reviewed_by_me=Exists(reviewed_exists))
except Exception:
reviewed_filter = True
# Restrict by category if provided and the model has such a relation # Restrict by category if provided and the model has such a relation
if category: if category:
try: try:
@@ -3152,6 +3169,23 @@ class GenericViewBase:
# Build base queryset of questions for this app # Build base queryset of questions for this app
qs = self.question_object.objects.all().order_by("pk") qs = self.question_object.objects.all().order_by("pk")
# Optionally filter to questions reviewed by the current user.
# Use an Exists subquery to avoid fetching extra rows.
reviewed_filter = (request.GET.get("reviewed") or request.POST.get("reviewed")) == "me"
if reviewed_filter:
try:
from django.db.models import Exists, OuterRef
# use the module-level ContentType (imported near file top)
ct = ContentType.objects.get_for_model(self.question_object)
reviewed_exists = QuestionReview.objects.filter(
content_type=ct, object_id=OuterRef("pk"), author=request.user
)
qs = qs.annotate(reviewed_by_me=Exists(reviewed_exists))
except Exception:
# If annotation fails for any reason, fall back to no-op and we'll filter in Python loop
reviewed_filter = True
# Restrict by category if provided # Restrict by category if provided
if category: if category:
try: try:
@@ -3166,6 +3200,20 @@ class GenericViewBase:
except Exception: except Exception:
pass pass
# Annotate with latest review status using a DB subquery for efficiency
try:
from django.db.models import OuterRef, Subquery
ct = ContentType.objects.get_for_model(self.question_object)
latest_status_subq = (
QuestionReview.objects.filter(content_type=ct, object_id=OuterRef("pk"))
.order_by("-created_on")
.values("status")[:1]
)
qs = qs.annotate(latest_review_status=Subquery(latest_status_subq))
except Exception:
# annotation failed — we'll fall back to per-object lookup below
pass
# Optionally filter by review status # Optionally filter by review status
results = [] results = []
for question in qs: for question in qs:
@@ -3177,25 +3225,46 @@ class GenericViewBase:
except Exception: except Exception:
pass pass
ct = ContentType.objects.get_for_model(question) # Determine latest status — prefer annotated value if present
latest = ( latest_status = getattr(question, "latest_review_status", None)
QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
.order_by("-created_on").first()
)
match = False match = False
if status == "ANY": if status == "ANY":
match = True match = True
elif status == "UNREVIEWED": elif status == "UNREVIEWED":
match = latest is None match = latest_status is None
else: else:
if latest is not None and latest.status == status: if latest_status is not None and latest_status == status:
match = True match = True
if match: if match:
# If the caller requested only questions reviewed by the current user,
# skip any question that does not have a matching review.
if reviewed_filter:
# If annotation was applied this attribute will be present on the instance.
if hasattr(question, "reviewed_by_me"):
if not question.reviewed_by_me:
continue
else:
# Last-resort check via DB lookup
ct = ContentType.objects.get_for_model(question)
if not QuestionReview.objects.filter(content_type=ct, object_id=question.pk, author=request.user).exists():
continue
results.append(question) results.append(question)
return render(request, f"{self.app_name}/question_review_list.html", {"questions": results, "app_name": self.app_name, "filters": {"category": category, "status": status}}) # Provide human-readable labels for statuses to the template
status_choices = {code: label for code, label in QuestionReview.StatusChoices.choices}
return render(
request,
f"{self.app_name}/question_review_list.html",
{
"questions": results,
"app_name": self.app_name,
"filters": {"category": category, "status": status},
"status_choices": status_choices,
},
)
@method_decorator(user_passes_test(lambda u: u.is_superuser)) @method_decorator(user_passes_test(lambda u: u.is_superuser))
@@ -3808,6 +3877,78 @@ def user_group_view_all(request):
) )
@login_required
def user_toggle_group(request, user_id, group_id):
"""Toggle membership of a user in either a UserUserGroup or an auth Group.
Visible to superusers and users in the `cid_user_manager` group. The
caller may pass a querystring `?type=auth` to toggle a Django auth Group; by
default it toggles the project's UserUserGroup.
Returns the rendered groups fragment for HTMX swaps.
"""
if request.method != "POST":
return HttpResponse("Method not allowed", status=405)
# Allow superusers or members of the cid_user_manager group
if not (
request.user.is_superuser
or request.user.groups.filter(name="cid_user_manager").exists()
):
return HttpResponse("Forbidden", status=403)
user = get_object_or_404(User, pk=user_id)
gtype = request.GET.get("type", "custom")
if gtype == "auth":
# Toggle Django auth Group membership
auth_group = get_object_or_404(Group, pk=group_id)
if auth_group.user_set.filter(pk=user.pk).exists():
auth_group.user_set.remove(user)
else:
auth_group.user_set.add(user)
else:
# Toggle project UserUserGroup membership
group = get_object_or_404(UserUserGroup, pk=group_id)
if group.users.filter(pk=user.pk).exists():
group.users.remove(user)
else:
group.users.add(user)
# Prepare available lists for the fragment
available_groups = UserUserGroup.objects.filter(archive=False).exclude(
pk__in=user.user_groups.values_list("pk", flat=True)
)
available_auth_groups = Group.objects.exclude(
pk__in=user.groups.values_list("pk", flat=True)
)
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
if gtype == "auth":
return render(
request,
"generic/partials/auth_groups_fragment.html",
{
"user": user,
"available_auth_groups": available_auth_groups,
"is_cid_user_manager": is_cid_user_manager,
},
)
return render(
request,
"generic/partials/user_groups_fragment.html",
{
"user": user,
"available_groups": available_groups,
"available_auth_groups": available_auth_groups,
"is_cid_user_manager": is_cid_user_manager,
},
)
@login_required @login_required
@user_is_cid_user_manager @user_is_cid_user_manager
def group_email_resend(request, pk): def group_email_resend(request, pk):
@@ -4369,7 +4510,6 @@ def trainees_bulk_update_from_spreadsheet(request):
# Fall back to automatic match # Fall back to automatic match
if not user: if not user:
user = row.get("matched_user") user = row.get("matched_user")
if not user: if not user:
skipped += 1 skipped += 1
continue continue
@@ -4399,50 +4539,6 @@ def trainees_bulk_update_from_spreadsheet(request):
) )
@user_is_cid_user_manager
def user_search(request):
"""Simple JSON endpoint to search users by name/email for manual matching."""
q = request.GET.get("q", "").strip()
results = []
if q:
qs = User.objects.filter(
Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(email__icontains=q)
).order_by("last_name", "first_name")[:50]
for u in qs:
results.append({"id": u.pk, "text": f"{u.first_name} {u.last_name} ({u.email})"})
# If this is an HTMX request (or a row param was provided) render an HTML fragment
# that can be swapped into the preview row's results container. Otherwise return JSON.
row = request.GET.get("row")
if request.headers.get("HX-Request") == "true" or row is not None:
# Render a small partial that lists results with Select buttons that call
# the setManualUser(row, id, text) function in the parent page.
return render(request, "generic/partials/user_search_results.html", {"results": results, "row": int(row) if row is not None else None})
return JsonResponse({"results": results})
@user_is_cid_user_manager
def supervisor_search(request):
"""Simple JSON/HTMX endpoint to search supervisors by name/email for manual matching."""
q = request.GET.get("q", "").strip()
results = []
if q:
qs = Supervisor.objects.filter(
Q(name__icontains=q) | Q(email__icontains=q)
).order_by("name")[:50]
for s in qs:
results.append({"id": s.pk, "text": f"{s.name} ({s.email})"})
row = request.GET.get("row")
if request.headers.get("HX-Request") == "true" or row is not None:
return render(request, "generic/partials/supervisor_search_results.html", {"results": results, "row": int(row) if row is not None else None})
return JsonResponse({"results": results})
def create_trainee(request, context=None): def create_trainee(request, context=None):
return create_user(request, context, trainee=True) return create_user(request, context, trainee=True)
@@ -4713,12 +4809,17 @@ class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView):
# Do permission checks here # Do permission checks here
obj = context["object"] obj = context["object"]
if ( if self.request.user in obj.get_author_objects():
return context
elif (
self.request.user.groups.filter(name="long_checker").exists() self.request.user.groups.filter(name="long_checker").exists()
or self.request.user.is_superuser or self.request.user.is_superuser
): ):
return context return context
if self.request.user in obj.get_author_objects():
review_group = f"{obj.get_app_name()}_reviewer"
logger.debug(f"Checking for group membership: {review_group}")
if self.request.user.groups.filter(name=review_group).exists():
return context return context
raise PermissionDenied() # or Http404 raise PermissionDenied() # or Http404
@@ -4901,4 +5002,59 @@ def cimar_case_refresh(request, uuid):
return HttpResponse("Case refreshed") return HttpResponse("Case refreshed")
@login_required
def user_search(request):
"""HTMX endpoint to search users for the trainees bulk-update UI.
Expects GET params:
- q: query string
- row: optional row index (passed back to JS)
Returns a small HTML fragment listing up to 10 matching users. Each
result calls `setManualUser(row, id, text)` when clicked (see template
JS in `trainees_bulk_update.html`).
"""
q = (request.GET.get("q") or "").strip()
row = request.GET.get("row")
if not q:
return HttpResponse("")
users_qs = User.objects.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(username__icontains=q)
).order_by("last_name", "first_name")[:10]
results = [{"id": u.pk, "text": f"{u.get_full_name() or u.username} - {u.email or ''}"} for u in users_qs]
return render(request, "generic/partials/user_search_results.html", {"results": results, "row": row, "q": q})
@login_required
def supervisor_search(request):
"""HTMX endpoint to search supervisors for the trainees bulk-update UI.
Expects GET params:
- q: query string
- row: optional row index (passed back to JS)
Returns a small HTML fragment listing up to 10 matching supervisors. Each
result calls `setManualSupervisor(row, id, text)` when clicked (see
template JS in `trainees_bulk_update.html`).
"""
q = (request.GET.get("q") or "").strip()
row = request.GET.get("row")
if not q:
return HttpResponse("")
supervisors_qs = Supervisor.objects.filter(
Q(name__icontains=q) | Q(email__icontains=q)
).order_by("name")[:10]
results = [{"id": s.pk, "text": f"{s.name} - {s.email or ''}"} for s in supervisors_qs]
return render(request, "generic/partials/supervisor_search_results.html", {"results": results, "row": row, "q": q})
+4 -4
View File
@@ -1327,13 +1327,13 @@ details.help-text > summary {
padding-bottom: 1px; padding-bottom: 1px;
} }
a, a:link { /* a, a:link {
color: #3282b8; color: #3282b8;
} } */
.navbar .nav-link { /* .navbar .nav-link {
color: #3282b8; color: #3282b8;
} } */
span#user-id { span#user-id {
opacity: 50%; opacity: 50%;
+37 -3
View File
@@ -80,8 +80,10 @@ from generic.models import (
Supervisor, Supervisor,
UserGrades, UserGrades,
UserProfile, UserProfile,
USER_EXAM_TYPES USER_EXAM_TYPES,
) )
from django.contrib.auth.models import Group
from generic.models import UserUserGroup
from django_filters.views import FilterView from django_filters.views import FilterView
@@ -117,7 +119,23 @@ def people(request):
@login_required @login_required
def profile(request): def profile(request):
user = request.user user = request.user
return render(request, "profile.html", {"user": user}) available_groups = UserUserGroup.objects.filter(archive=False).exclude(
pk__in=user.user_groups.values_list("pk", flat=True)
)
available_auth_groups = Group.objects.exclude(
pk__in=user.groups.values_list("pk", flat=True)
)
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
return render(
request,
"profile.html",
{
"user": user,
"available_groups": available_groups,
"available_auth_groups": available_auth_groups,
"is_cid_user_manager": is_cid_user_manager,
},
)
def index(request): def index(request):
@@ -141,7 +159,23 @@ def index(request):
@user_is_cid_user_manager @user_is_cid_user_manager
def account_profile(request, slug): def account_profile(request, slug):
user = get_object_or_404(User, username=slug) user = get_object_or_404(User, username=slug)
return render(request, "profile.html", {"user": user}) available_groups = UserUserGroup.objects.filter(archive=False).exclude(
pk__in=user.user_groups.values_list("pk", flat=True)
)
available_auth_groups = Group.objects.exclude(
pk__in=user.groups.values_list("pk", flat=True)
)
is_cid_user_manager = request.user.groups.filter(name="cid_user_manager").exists()
return render(
request,
"profile.html",
{
"user": user,
"available_groups": available_groups,
"available_auth_groups": available_auth_groups,
"is_cid_user_manager": is_cid_user_manager,
},
)
def cid_selector(request): def cid_selector(request):
+35
View File
@@ -24,6 +24,8 @@ from django.forms.widgets import RadioSelect, TextInput, Textarea
from tinymce.widgets import TinyMCE from tinymce.widgets import TinyMCE
from dal import autocomplete from dal import autocomplete
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Fieldset, Row, Column, Submit
class UserAnswerForm(ModelForm): class UserAnswerForm(ModelForm):
@@ -105,6 +107,39 @@ class QuestionForm(ModelForm):
widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False),
) )
# Crispy Forms helper and layout
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_enctype = "multipart/form-data"
self.helper.template_pack = "bootstrap5"
self.helper.layout = Layout(
Fieldset(
"Question",
"title",
"stem",
),
Fieldset(
"Answers",
# Use responsive columns so they stack on small screens and sit side-by-side on md+
Row(Column("a_answer", css_class="col-md-6"), Column("a_feedback", css_class="col-md-6")),
Row(Column("b_answer", css_class="col-md-6"), Column("b_feedback", css_class="col-md-6")),
Row(Column("c_answer", css_class="col-md-6"), Column("c_feedback", css_class="col-md-6")),
Row(Column("d_answer", css_class="col-md-6"), Column("d_feedback", css_class="col-md-6")),
Row(Column("e_answer", css_class="col-md-6"), Column("e_feedback", css_class="col-md-6")),
"best_answer",
),
Fieldset(
"Feedback",
"feedback",
),
Fieldset(
"Metadata",
Row(Column("category", css_class="col-md-6"), Column("exams", css_class="col-md-6")),
Row(Column("open_access", css_class="col-md-4"), Column("frcr_appropriate", css_class="col-md-4")),
),
)
self.helper.add_input(Submit("submit", "Submit", css_class="btn btn-primary"))
def save(self, commit=True): def save(self, commit=True):
# Get the unsaved Pizza instance # Get the unsaved Pizza instance
instance = ModelForm.save(self, False) instance = ModelForm.save(self, False)
+1 -1
View File
@@ -96,7 +96,7 @@
<div class="mb-3"> <div class="mb-3">
<h5 class="h6">Feedback</h5> <h5 class="h6">Feedback</h5>
<div>{{ question.feedback|linebreaks }}</div> <div>{{ question.feedback|safe|linebreaks }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
+28 -13
View File
@@ -2,6 +2,7 @@
{% load static %} {% load static %}
{% load partials %} {% load partials %}
{% load crispy_forms_tags %}
{% block js %} {% block js %}
<!-- <script type="text/javascript" src="/admin/jsi18n/"></script> --> <!-- <script type="text/javascript" src="/admin/jsi18n/"></script> -->
@@ -11,19 +12,33 @@
{{ form.media }} {{ form.media }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-start mb-3 flex-column flex-md-row"> <div class="container py-3">
<div> <div class="d-flex justify-content-between align-items-start mb-3 flex-column flex-md-row">
<h2>Submit Question</h2> <div>
<h2 class="h4 mb-2">Submit Question</h2>
</div>
{% if question %}
{% include 'sbas/question_detail.html#links-partial' %}
{% endif %}
</div>
<div class="card">
<div class="card-body">
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
{{ form.media }}
<div class="mb-3">
{% crispy form %}
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url 'sbas:index' %}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div> </div>
{% if question %}
{% include 'sbas/question_detail.html#links-partial' %}
{% endif %}
</div> </div>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<table>{{ form.as_table }}</table>
<input type="submit" class="submit-button" value="Submit" name="submit" />
</form>
{% endblock %} {% endblock %}
+23 -1
View File
@@ -1,10 +1,22 @@
{% extends 'sbas/base.html' %} {% extends 'sbas/base.html' %}
{% load partials %}
{% block content %} {% block content %}
<h2>Filtered questions</h2> <h2>Filtered questions</h2>
<p>Showing questions matching the selected filters.</p> <p>Showing questions matching the selected filters.</p>
<div class="mb-2">
<form method="get" class="d-inline">
<input type="hidden" name="reviewed" value="me" />
{% if filters.category %}<input type="hidden" name="category" value="{{ filters.category }}" />{% endif %}
{% if filters.status %}<input type="hidden" name="status" value="{{ filters.status }}" />{% endif %}
<button type="submit" class="btn btn-sm btn-outline-primary">Show only questions I've reviewed</button>
</form>
<a href="?" class="btn btn-sm btn-outline-secondary ms-2">Clear filters</a>
</div>
<div class="mb-3"> <div class="mb-3">
Active filters: Active filters:
{% if filters.category %} {% if filters.category %}
@@ -24,8 +36,18 @@
{% for q in questions %} {% for q in questions %}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<div> <div>
<a href="{% url app_name|add:':question_detail' q.pk %}">{{ q.title|default:q.stem|truncatechars:80 }}</a> <a href="{% url app_name|add:':question_detail' q.pk %}">{{ q.title|default:q.stem|truncatechars:80|safe }}</a>
<div class="small text-muted">ID: {{ q.pk }}</div> <div class="small text-muted">ID: {{ q.pk }}</div>
{% comment %} Show latest review status if available {% endcomment %}
{% with status_code=q.latest_review_status %}
{% if status_code %}
{% with label=status_choices|get_item:status_code %}
<div class="mt-1"><span class="badge bg-secondary">Latest review: {{ label }}</span></div>
{% endwith %}
{% else %}
<div class="mt-1"><span class="text-muted small">Latest review: Unreviewed</span></div>
{% endif %}
{% endwith %}
</div> </div>
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' q.pk %}">View</a> <a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' q.pk %}">View</a>
@@ -25,6 +25,8 @@
</div> </div>
<button type="submit" class="btn btn-primary">Start review</button> <button type="submit" class="btn btn-primary">Start review</button>
<div class="mb-3"> <div class="mb-3">
@@ -40,7 +42,13 @@
<button id="view-list-btn" type="button" class="btn btn-outline-primary">View list</button> <button id="view-list-btn" type="button" class="btn btn-outline-primary">View list</button>
</div> </div>
</form> </form>
</form>
<!-- Checkbox outside the POST form so it only affects the View List flow -->
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" value="me" id="id_reviewed_me">
<label class="form-check-label" for="id_reviewed_me">Only questions I've reviewed (applies to View list)</label>
</div>
<script> <script>
// When View list is clicked, read the values from the main filter form and navigate // When View list is clicked, read the values from the main filter form and navigate
@@ -51,8 +59,10 @@
const params = new URLSearchParams(); const params = new URLSearchParams();
const categoryEl = document.getElementById('id_category'); const categoryEl = document.getElementById('id_category');
const statusEl = document.getElementById('id_status'); const statusEl = document.getElementById('id_status');
const reviewedEl = document.getElementById('id_reviewed_me');
if (categoryEl && categoryEl.value) params.set('category', categoryEl.value); if (categoryEl && categoryEl.value) params.set('category', categoryEl.value);
if (statusEl && statusEl.value) params.set('status', statusEl.value); if (statusEl && statusEl.value) params.set('status', statusEl.value);
if (reviewedEl && reviewedEl.checked) params.set('reviewed', reviewedEl.value || 'me');
const url = base + (params.toString() ? ('?' + params.toString()) : ''); const url = base + (params.toString() ? ('?' + params.toString()) : '');
window.location.href = url; window.location.href = url;
}); });
+6 -2
View File
@@ -669,8 +669,12 @@ def toggle_frcr_appropriate(request, pk):
question = get_object_or_404(Question, pk=pk) question = get_object_or_404(Question, pk=pk)
# Simple permission: allow staff or users with change permission # Simple permission: allow staff or users with change permission
if not (request.user.is_staff or request.user.has_perm('sbas.change_question')): if question.is_author(request.user):
return HttpResponseForbidden("Forbidden") pass
elif request.user.groups.filter(name="sba_reviewer").exists():
pass
elif not (request.user.is_staff or request.user.has_perm('sbas.change_question')):
return HttpResponse("Forbidden")
# Toggle or set # Toggle or set
if request.method == "POST": if request.method == "POST":
+85 -2
View File
@@ -157,14 +157,14 @@
users.push(user) users.push(user)
emails.push(user.email) emails.push(user.email)
console.log(user) console.log(user)
$("#users-list").append(`<li>Name: <span class="name">${user.first_name} ${user.last_name}</span><br/>Email: ${user.email}<br/>Grade: ${user.grade}<br/>Supervisor: ${user.supervisor_name}<br/>Supervisor email: ${user.supervisor_email}<br/> $("#users-list").append(`<li data-index="${i}">Name: <span class="name">${user.first_name} ${user.last_name}</span><br/>Email: <span class="email">${user.email}</span><br/>Grade: <span class="grade">${user.grade}</span><br/>Supervisor: <span class="supervisor_name">${user.supervisor_name || ''}</span><br/>
<form> <form>
Supervisor email: <input class="supervisor-email-input" name="supervisor_email" type="text" value="${user.supervisor_email || ''}" placeholder="supervisor email" style="width: 320px; display: none;" /> <br/>
<input type="text" name="first_name" value="${user.first_name}" hidden> <input type="text" name="first_name" value="${user.first_name}" hidden>
<input type="text" name="last_name" value="${user.last_name}" hidden> <input type="text" name="last_name" value="${user.last_name}" hidden>
<input type="text" name="email" value="${user.email}" hidden> <input type="text" name="email" value="${user.email}" hidden>
<input type="text" name="grade" value="${user.grade}" hidden> <input type="text" name="grade" value="${user.grade}" hidden>
<input type="text" name="supervisor_name" value="${user.supervisor_name}" hidden> <input type="text" name="supervisor_name" value="${user.supervisor_name}" hidden>
<input type="text" name="supervisor_email" value="${user.supervisor_email}" hidden>
<button <button
hx-post="{% url 'accounts_bulk_create_check' %}" hx-post="{% url 'accounts_bulk_create_check' %}"
hx-trigger="load" hx-trigger="load"
@@ -186,21 +186,104 @@
// Make sure the new hyperscript works // Make sure the new hyperscript works
_hyperscript.processNode($("#users-list")[0]); _hyperscript.processNode($("#users-list")[0]);
htmx.process($("#users-list")[0]); htmx.process($("#users-list")[0]);
$("#user-list-json").val(JSON.stringify(users));
// Run the supervisor input toggle once HTMX has processed the checks
updateSupervisorInputs();
$("#user-list-json").val(JSON.stringify(users)); $("#user-list-json").val(JSON.stringify(users));
$("#submit-users").show(); $("#submit-users").show();
// When submit is clicked, rebuild users JSON from current DOM values (to pick up any edited supervisor emails)
window.prepareAndSubmit = function(){
var rebuilt = [];
$("#users-list li").each(function(idx, li){
var $li = $(li);
var first_last = $li.find('.name').text().trim();
var parts = first_last.split(/\s+/);
var first = parts[0] || '';
var last = parts.slice(1).join(' ') || '';
var email = $li.find('.email').text().trim();
var grade = $li.find('.grade').text().trim();
var supervisor_name = $li.find('.supervisor_name').text().trim();
var supervisor_email = $li.find('.supervisor-email-input').val().trim();
rebuilt.push({
first_name: first,
last_name: last,
email: email,
grade: grade,
supervisor_name: supervisor_name,
supervisor_email: supervisor_email
});
});
$("#user-list-json").val(JSON.stringify(rebuilt));
// submit the form
document.getElementById('user-list-form').submit();
};
// Attach form submit handler to rebuild JSON just before submit (covers cases where user presses Enter)
$('#user-list-form').on('submit', function(e){
e.preventDefault();
// rebuild
var rebuilt = [];
$("#users-list li").each(function(idx, li){
var $li = $(li);
var first_last = $li.find('.name').text().trim();
var parts = first_last.split(/\s+/);
var first = parts[0] || '';
var last = parts.slice(1).join(' ') || '';
var email = $li.find('.email').text().trim();
var grade = $li.find('.grade').text().trim();
var supervisor_name = $li.find('.supervisor_name').text().trim();
var supervisor_email = $li.find('.supervisor-email-input').val().trim();
rebuilt.push({
first_name: first,
last_name: last,
email: email,
grade: grade,
supervisor_name: supervisor_name,
supervisor_email: supervisor_email
});
});
$("#user-list-json").val(JSON.stringify(rebuilt));
// submit without triggering this handler again
$(this).off('submit');
this.submit();
});
// If we want to show the table // If we want to show the table
//document.body.appendChild(myTable); //document.body.appendChild(myTable);
document.addEventListener('htmx:afterRequest', function(evt) { document.addEventListener('htmx:afterRequest', function(evt) {
updateWarnings(); updateWarnings();
updateSupervisorInputs();
}); });
} }
</script> </script>
<script>
// Hide the supervisor email input if the server has indicated the supervisor exists
function updateSupervisorInputs(){
try {
document.querySelectorAll('#users-list li').forEach(function(li){
var statusSpan = li.querySelector('.check-status');
if (!statusSpan) return;
var text = statusSpan.textContent.trim().toLowerCase();
var input = li.querySelector('.supervisor-email-input');
if (!input) return;
// If the server response indicates the supervisor exists (and not 'does not exist'), hide the input
if (text.indexOf('supervisor exists') !== -1 && text.indexOf('does not exist') === -1) {
input.style.display = 'none';
} else {
input.style.display = '';
}
});
} catch (e) {
console.error('updateSupervisorInputs error', e);
}
}
</script>
<script type="text/hyperscript"> <script type="text/hyperscript">
def updateWarnings() def updateWarnings()
log "test" log "test"
+3 -2
View File
@@ -201,7 +201,7 @@
<a class="nav-link active" href="{% url 'atlas:index' %}">Atlas</a> <a class="nav-link active" href="{% url 'atlas:index' %}">Atlas</a>
</li> </li>
<li class="nav-item dropdown pt-0"> <li class="nav-item dropdown pt-0">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle active" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
FRCR FRCR
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
@@ -334,6 +334,7 @@ document.addEventListener('DOMContentLoaded', function() {
//} //}
// fallback to any table on the page that looks like it has selection // fallback to any table on the page that looks like it has selection
var any = document.querySelector('table.js-row-selectable') //|| Array.from(document.querySelectorAll('table')).find(function(t){ return t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]')); }); var any = document.querySelector('table.js-row-selectable') //|| Array.from(document.querySelectorAll('table')).find(function(t){ return t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]')); });
console.log('findTableForControl fallback', any);
return any || null; return any || null;
} }
@@ -374,7 +375,7 @@ document.addEventListener('DOMContentLoaded', function() {
container.parentNode.insertBefore(wrapper, container); container.parentNode.insertBefore(wrapper, container);
} }
// Create controls for any table that looks selectable but lacks a control block //// Create controls for any table that looks selectable but lacks a control block
Array.from(document.querySelectorAll('table')).forEach(function(t){ Array.from(document.querySelectorAll('table')).forEach(function(t){
if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) { if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) {
ensureControlsForTable(t); ensureControlsForTable(t);
+111 -77
View File
@@ -1,92 +1,126 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load auth_extras %} {% load auth_extras %}
{% load help_tags %}
{% block content %} {% block content %}
<h1>PENRA Courses</h1> <div class="py-4">
{% if not request.user.is_authenticated %} <div class="container">
<a href="{% url 'login'%}">Log in</a><br/> <div class="row mb-4">
<a href="{% url 'password_reset'%}">Reset password</a> <div class="col-12">
{% else %} <div class="p-4 bg-dark rounded-3 border">
{% comment %} <a href="{% url 'atlas:index'%}">Atlas</a> <div class="d-flex justify-content-between align-items-center">
<a href="{% url 'anatomy:index'%}">Anatomy</a> <h1 class="mb-0">PENRA Courses</h1>
<a href="{% url 'physics:index'%}">Physics</a> {% if not request.user.is_authenticated %}
<a href="{% url 'rapids:index'%}">Rapids</a> {% comment %} <div>
<a href="{% url 'longs:index'%}">Longs</a> <a class="btn btn-outline-primary me-2" href="{% url 'login' %}">Log in</a>
<a href="{% url 'sbas:index'%}">SBAs</a> {% endcomment %} <a class="btn btn-outline-secondary" href="{% url 'password_reset' %}">Reset password</a>
{% endif %} </div> {% endcomment %}
{% if request.user.is_authenticated %} {% else %}
<a class="btn btn-outline-light" href="{% url 'profile' %}">Your profile</a>
{% endif %}
{% if request.user.userprofile.peninsula_trainee %} </div>
<div id="user-details card"> <p class="text-muted mt-2">Access exams, courses and resources for trainees and trainers.</p>
<div class="card-body"> </div>
<h5 class="card-title">Please check your details are correct.</h5>
Name: {{request.user.first_name}} {{request.user.last_name}}<br/>
Email: {{request.user.email}}<br/>
Grade: {{request.user.userprofile.grade}}<br/>
Supervisor: {{request.user.userprofile.supervisor}}
</div> </div>
</div> </div>
{% endif %} <div class="row g-4">
<div class="col-lg-8">
{% if request.user.is_authenticated %}
{% if request.user.userprofile.peninsula_trainee %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Profile</h5>
<p class="mb-1"><strong>Name:</strong> {{ request.user.first_name }} {{ request.user.last_name }}</p>
<p class="mb-1"><strong>Email:</strong> {{ request.user.email }}</p>
<p class="mb-1"><strong>Grade:</strong> {{ request.user.userprofile.grade }}</p>
<p class="mb-0"><strong>Supervisor:</strong> {{ request.user.userprofile.supervisor }}</p>
<div role="alert" class="alert alert-warning mt-3">
If any of this information is incorrect, please update it via your <a href="{% url 'profile' %}">profile page</a>
</div>
</div>
</div>
{% endif %}
{% if request.user.supervisor %} <div class="card mb-3">
<a href='{% url "generic:supervisor_overview" request.user.supervisor.pk %}'>Supervisor overview</a> <div class="card-body">
{% endif %} <h5 class="card-title">Exams / Courses</h5>
<p class="card-text">View your courses and exam scores.</p>
<a class="btn btn-outline-primary" href="{% url 'user_scores' %}">View my courses</a>
</div>
</div>
{% else %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Peninsula Trainees</h5>
<p class="card-text">Please log in to access your exams and courses.</p>
<a class="btn btn-outline-primary me-2" href="{% url 'login' %}">Log in</a>
<a class="btn btn-outline-secondary" href="{% url 'password_reset' %}">Reset password</a>
</div>
</div>
<div class="alert alert-info" role="alert">
<h5 class="alert-heading mb-1">Not a Peninsula trainee?</h5>
<p class="mb-0">If you are an external user, please <a href="{% url 'cid_selector' %}">log in via CID</a>.</p>
{% help "Help" %}
You should have received your candidate ID and login details by email or directly from your course organiser. If you have not, please contact them for assistance.
{% endhelp %}
</div>
{% endif %}
{% if collections %}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Exam collections</h5>
<div class="list-group list-group-flush">
{% for collection in collections %}
<a class="list-group-item list-group-item-action" href="{% url 'generic:examcollection_detail' collection.pk %}">{{ collection.name }}</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<h3>Exams / Courses</h3> {% if rcr_assessor %}
<p> <div class="alert alert-info" role="alert">
View your courses / exams <a href="{% url 'user_scores'%}">here</a> <h5 class="alert-heading mb-1">RCR platform migration</h5>
<p class="mb-0"><a href="{% url 'rcr:radiology_index_view' %}">Open RCR migration tools</a></p>
</div>
{% endif %}
</div>
</p> <div class="col-lg-4">
{% endif %} <div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Quick links</h6>
<ul class="list-unstyled mb-0">
<li><a href="http://www.penracourses.org.uk/rts" class="link-primary">RTS platform</a></li>
{% if not request.user.is_authenticated %}<li><a href="{% url 'cid_selector' %}" class="link-primary">CID login</a></li>{% endif %}
{% if request.user.supervisor %}<li><a href='{% url "generic:supervisor_overview" request.user.supervisor.pk %}' class="link-primary">Supervisor overview</a></li>{% endif %}
{% if request.user|has_group:"cid_user_manager" %}
<li>Manage <a href="{% url 'trainees' %}">trainees</a> · <a href="{% url 'accounts_list' %}">users</a> · <a href="{% url 'generic:manage_cids' %}">candidates</a></li>
{% endif %}
{% if request.user.is_superuser %}
<li><a href="{% url 'django_psutil_dash:dashboard' %}" class="link-primary">psutil</a></li>
{% endif %}
</ul>
</div>
</div>
<div class="card text-muted small">
<div class="card-body">
<strong>Need help?</strong>
<p class="mb-0">See <a href="{% url 'about' %}">About</a> or contact your admin.</p>
</div>
</div>
</div>
</div>
{% if collections %} <div class="row mt-4">
<h2>Exam collections</h2> <div class="col-12 text-center">
<ul> {% if request.user.is_authenticated %}
{% for collection in collections %} {% if request.user|has_group:"cid_user_manager" %}<a class="btn btn-sm btn-outline-secondary me-2" href="{% url 'admin:index' %}">Admin</a>{% endif %}
<li><a href="{% url 'generic:examcollection_detail' collection.pk %}">{{collection.name}}</a></li> {% endif %}
{% endfor %} </div>
</ul> </div>
{% endif %}
<p><a href="http://www.penracourses.org.uk/rts">RTS is available here</a></p>
{% if rcr_assessor %}
<div>
<h3><a href="{% url 'rcr:radiology_index_view'%}">RCR platform migration</a></h3>
</div> </div>
{% endif %}
{% if not request.user.is_authenticated %}
<p><a href="{% url 'cid_selector' %}">CID users can log in here</a></p>
{% endif %}
<div>
{% if request.user|has_group:"cid_user_manager" %}
Manage <a href="{% url 'trainees' %}">trainees</a><br/>
Manage users <a href="{% url 'accounts_list'%}">here</a> and candidates <a href="{% url 'generic:manage_cids'%}">here</a>
<p>Manage <a href="{% url 'generic:examination_view' %}">Examinations</a>
</p>
<a href="{% url 'admin:index'%}">Admin</a>
<a href="{% url 'rcr:radiology_index_view'%}">RCR platform migration</a>
{% endif %}
</div> </div>
{% if request.user.is_superuser %}
<a href="{% url 'django_psutil_dash:dashboard' %}">psutil</a>
{% endif %}
{% endblock %} {% endblock %}
+122 -58
View File
@@ -2,67 +2,131 @@
{% load auth_extras %} {% load auth_extras %}
{% block content %} {% block content %}
<div class="anatomy"> <div class="container py-4">
<h1>Profile</h1> <div class="row">
<div> <div class="col-md-4 mb-3">
Username: <span id="username">{{ user.username }}</span> <div class="card">
</div> <div class="card-body text-center">
<div> <div class="profile-avatar mb-3">
Email: <span id="email">{{ user.email }}</span> <div class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center" style="width:96px;height:96px;font-size:32px;">{{ user.first_name|default:user.username|slice:":1"|upper }}</div>
</div> </div>
<div> <h4 class="card-title mb-1">{{ user.get_full_name|default:user.username }}</h4>
Name: <span id="first-name">{{ user.first_name }}<span> <p class="text-muted mb-1">@{{ user.username }}</p>
<span id="last-name">{{ user.last_name }}</span> <p class="mb-0"><a href="mailto:{{ user.email }}">{{ user.email }}</a></p>
</div> <div class="mt-2">
<div> <small class="text-muted">Permission groups:</small>
Grade: {{ user.userprofile.grade }} <div id="auth-groups-{{ user.pk }}" class="mt-1 d-flex flex-wrap gap-2 align-items-center">
</div> {% if user.groups.all %}
<div> {% for perm_group in user.groups.all %}
Supervisor: {{ user.userprofile.supervisor }} {% if request.user.is_superuser or is_cid_user_manager %}
{% if request.user.is_superuser and user.userprofile.supervisor %} <button
<a href='{% url "generic:supervisor_detail" user.userprofile.supervisor.pk %}'><i class="bi bi-link"></i></a> class="btn btn-sm btn-info text-dark"
{% endif %} hx-post="{% url 'generic:user_toggle_group' user.pk perm_group.pk %}?type=auth"
<br/> hx-target="#auth-groups-{{ user.pk }}"
</div> hx-swap="outerHTML"
<div> >
Registration number: {{ user.userprofile.registration_number }} {{ perm_group.name }}
</div> <span class="ms-1 text-muted small">&times;</span>
</button>
{% else %}
<span class="badge bg-info text-dark me-1">{{ perm_group.name }}</span>
{% endif %}
{% endfor %}
{% else %}
<span class="badge bg-light text-muted">No permission groups</span>
{% endif %}
{% if request.user.is_superuser or is_cid_user_manager %}
<h3>Groups</h3> <div class="dropdown">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" id="addAuthGroupDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<ul> Add
{% for group in user.user_groups.all %} </button>
<li><a href="{% url 'generic:user_group_detail' group.pk %}">{{group}}</a></li> <ul class="dropdown-menu" aria-labelledby="addAuthGroupDropdown">
{% empty %} {% if available_auth_groups.count %}
<li>No groups.</li> {% for ag in available_auth_groups %}
{% endfor %} <li>
</ul> <button
class="dropdown-item"
<h3>Exams</h3> hx-post="{% url 'generic:user_toggle_group' user.pk ag.pk %}?type=auth"
<p> hx-target="#auth-groups-{{ user.pk }}"
{% with user.userprofile.get_exams as exam_map %} hx-swap="outerHTML"
{% for exam_type, exams in exam_map.items %} >
<h4>{{exam_type}}</h4> {{ ag.name }}
<ul> </button>
{% for exam in exams %} </li>
<li> {% endfor %}
<a href="{{exam.get_absolute_url}}">{{exam}}</a> {% else %}
</li> {% if request.user.is_superuser %}
{% endfor %} <li>
<a class="dropdown-item" href="{% url 'admin:auth_group_add' %}" target="_blank" rel="noopener">Create permission group</a>
</li>
{% else %}
<li><span class="dropdown-item disabled">No groups available to add</span></li>
{% endif %}
{% endif %}
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">Grade: <strong>{{ user.userprofile.grade|default:"N/A" }}</strong></li>
<li class="list-group-item">Registration: <strong>{{ user.userprofile.registration_number|default:"N/A" }}</strong></li>
<li class="list-group-item">Supervisor:
{% if user.userprofile.supervisor %}
{{ user.userprofile.supervisor }}
{% if request.user.is_superuser %}
<a href="{% url 'generic:supervisor_detail' user.userprofile.supervisor.pk %}" class="ms-2"><i class="bi bi-box-arrow-up-right"></i></a>
{% endif %}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</li>
</ul> </ul>
{% empty %} <div class="card-body">
No exams. <a class="btn btn-outline-primary d-block mb-2" href="{% url 'password_change' %}">Change password</a>
{% endfor %} {% if request.user|has_group:"cid_user_manager" %}
{% endwith %} <a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
</p> {% endif %}
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}">Edit profile</a>
{% if request.user.is_superuser %}
<a class="btn btn-outline-danger d-block" href="{% url 'admin:auth_user_change' user.pk %}" target="_blank" rel="noopener">Admin edit</a>
{% endif %}
</div>
</div>
</div>
<div class="col-md-8">
<div class="card mb-3">
<div class="card-header">Groups</div>
<div class="card-body">
{% include 'generic/partials/user_groups_fragment.html' with user=user %}
</div>
</div>
<a id="password-change" href="{% url 'password_change'%}">Change password</a> <div class="card">
<div class="card-header">Exams</div>
{% if request.user|has_group:"cid_user_manager" %} <div class="card-body">
<a href="{% url 'account_update' user.username %}">Update user details</a> {% with user.userprofile.get_exams as exam_map %}
{% endif %} {% if exam_map %}
<a id="update-profile-link" href="{% url 'account_profile_update' user.username %}">Update user profile</a> {% for exam_type, exams in exam_map.items %}
<h5 class="mt-2">{{ exam_type }}</h5>
<ul class="list-group mb-2">
{% for exam in exams %}
<li class="list-group-item py-2">
<a href="{{ exam.get_absolute_url }}">{{ exam }}</a>
</li>
{% endfor %}
</ul>
{% endfor %}
{% else %}
<div class="text-muted">No exams.</div>
{% endif %}
{% endwith %}
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
+2
View File
@@ -10,6 +10,7 @@
<div class="d-flex justify-content-between mb-2 align-items-center flex-column flex-md-row"> <div class="d-flex justify-content-between mb-2 align-items-center flex-column flex-md-row">
<div class="mb-2 mb-md-0"> <div class="mb-2 mb-md-0">
<div class="btn-toolbar" role="toolbar"> <div class="btn-toolbar" role="toolbar">
{% if user.is_superuser %}
<div class="btn-group me-2" role="group"> <div class="btn-group me-2" role="group">
<button id="delete-selected-btn" class="btn btn-danger btn-sm" <button id="delete-selected-btn" class="btn btn-danger btn-sm"
hx-post="{% url 'generic:bulk_delete_questions' %}" hx-post="{% url 'generic:bulk_delete_questions' %}"
@@ -19,6 +20,7 @@
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">Delete selected</button> hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">Delete selected</button>
</div> </div>
{% endif %}
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button id="button-select-add-exam" class="btn btn-outline-primary btn-sm" <button id="button-select-add-exam" class="btn btn-outline-primary btn-sm"
hx-get="{% url 'generic:generic_exam_htmx' %}?type={{ app_name }}" hx-get="{% url 'generic:generic_exam_htmx' %}?type={{ app_name }}"
+111 -66
View File
@@ -1,78 +1,123 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load help_tags %}
{% block content %} {% block content %}
<div class=""> <div class="container py-4">
<h2>User: {{ cid_user.username }}</h2> <div class="row mb-3">
<div class="alert alert-dark" role="alert"> <div class="col-md-8">
<details> <h2 class="mb-1">User: {{ cid_user.username }}</h2>
<summary>Help</summary> {% help "Help" %}
<p>This page shows the exam(s) to which you have access.</p> <div class="card mt-2">
<p>When an exam is available to take it will have a "Start" button next to it.</p> <div class="card-body small">
<p>Once you have submitted answers you will be able to view them from the Exam results section. (Please note: although your answers will be visible as soon as they are submitted, correct answers will only be available when the exam results have been published.)</p> <p class="mb-1">This page shows the exam(s) to which you have access.</p>
<p>If you have not submitted any answer for an exam it will not appear in the Exam Results section below. In this case you can still review the pubilshed results by following the "Results Published" link in the assigend exams section.</p> <p class="mb-1">When an exam is available to take it will have a "Start" button next to it.</p>
</details> <p class="mb-1">Once you have submitted answers you will be able to view them from the Exam results section. (Please note: although your answers will be visible as soon as they are submitted, correct answers will only be available when the exam results have been published.)</p>
<p class="mb-0">If you have not submitted any answer for an exam it will not appear in the Exam Results section below. In this case you can still review the published results by following the "Results Published" link in the assigned exams section.</p>
</div>
</div>
{% endhelp %}
</div>
</div> </div>
<h3>Assigned Exams</h3>
<div id="exam-assigned"> <div class="row">
The following exams will be available to take when active. <div class="col-lg-8">
{% for exam_type, exams in available_exams %} <div class="card mb-3">
{% if exams %} <div class="card-header">
<h4>{{exam_type|title}}</h4> <h5 class="mb-0">Assigned Exams</h5>
<ul class='{{exam_type|lower}}'> </div>
{% for exam in exams %} <div class="card-body">
<li class="exam" data-exam-id="{{exam.pk}}">{{exam}} <p class="text-muted">The following exams will be available to take when active.</p>
{% if exam.active %} {% for exam_type, exams in available_exams %}
<a href="{{exam.get_take_url}}" target="_blank" title="Click to take exam"> {% if exams %}
<button class="small-url-button start-button">Start</button> <h6 class="mt-3">{{ exam_type|title }}</h6>
</a> <ul class="list-group">
{% endif %} {% for exam in exams %}
{% if exam.publish_results %} <li class="list-group-item d-flex justify-content-between align-items-center">
<a href= "{% url exam_type|lower|add:':exam_scores_user' pk=exam.pk %}" title="Click to view results"> <div>
<button class="small-url-button results-button">Results Published</button> {{ exam }}
</a> {% if exam.active %}<span class="badge bg-success ms-2">Active</span>{% endif %}
{% endif %} {% if exam.publish_results %}<span class="badge bg-info text-dark ms-2">Results Published</span>{% endif %}
</li> </div>
<div class="btn-group btn-group-sm" role="group">
{% if exam.active %}
<a class="btn btn-primary" href="{{ exam.get_take_url }}" target="_blank" title="Click to take exam">Start</a>
{% endif %}
{% if exam.publish_results %}
<a class="btn btn-outline-secondary" href="{% url exam_type|lower|add:':exam_scores_user' pk=exam.pk %}" title="Click to view results">Results</a>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %} {% endfor %}
</ul> </div>
{% endif %} </div>
{% endfor %}
</div>
{% if all_exams %} {% if all_exams %}
<h3>Exam Results</h3> <div class="card mb-3">
<div id="exam-results"> <div class="card-header">
The following exam results been found. Click to view answers (and scores when the results are published): <h5 class="mb-0">Exam Results</h5>
{% for exam_type, exams in all_exams %} </div>
{% if exams %} <div class="card-body">
<h4>{{exam_type|title}}</h4> <p class="text-muted">Click an exam to view answers (and scores when results are published).</p>
<ul class='{{exam_type|lower}}'> {% for exam_type, exams in all_exams %}
{% for exam in exams %} {% if exams %}
<li class="exam" data-exam-id="{{exam.pk}}"> <h6 class="mt-3">{{ exam_type|title }}</h6>
<ul class="list-group">
{% if admin %} {% for exam in exams %}
<a href= "{% url exam_type|add:':exam_scores_user_admin' pk=exam.pk user_id=user.pk %}" title="Click to view results"> <li class="list-group-item d-flex justify-content-between align-items-center">
{% else %} <div>
<a href= "{% url exam_type|add:':exam_scores_user' pk=exam.pk %}" title="Click to view results"> {% if admin %}
{% endif %} <a href="{% url exam_type|add:':exam_scores_user_admin' pk=exam.pk user_id=user.pk %}">{{ exam }}</a>
{% else %}
{{exam}}</a> {% if exam.active %}[Active]{% endif %} {% if exam.publish_results %}[Results Published]{% endif %}</li> <a href="{% url exam_type|add:':exam_scores_user' pk=exam.pk %}">{{ exam }}</a>
{% endif %}
{% if exam.active %}<span class="badge bg-success ms-2">Active</span>{% endif %}
{% if exam.publish_results %}<span class="badge bg-info text-dark ms-2">Results Published</span>{% endif %}
</div>
<div class="small text-muted">&nbsp;</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %} {% endfor %}
</ul> </div>
{% endif %} </div>
{% endfor %} {% endif %}
{% if case_collections %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Case Collections</h5>
</div>
<div class="card-body">
<p class="text-muted">The following Case Collections have been found.</p>
<ul class="list-group">
{% for collection in case_collections %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<a href="{{ collection.get_take_url }}?cid={{ cid }}&passcode={{ passcode }}">{{ collection.name }}</a>
<div>
{% if collection.active %}<span class="badge bg-success">Active</span>{% endif %}
{% if collection.publish_results %}<span class="badge bg-info text-dark ms-2">Results Published</span>{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div> </div>
{% endif %}
{% if case_collections %} <div class="col-lg-4">
<div> <div class="card mb-3">
<h3>Case Collection</h3> <div class="card-body">
The following Case Collections have been found. <h6 class="card-title">Actions</h6>
{% for collection in case_collections %} <p class="small text-muted">Quick links and information.</p>
<li><a href="{{collection.get_take_url}}?cid={{cid}}&passcode={{passcode}}">{{collection.name}}</a> {% if collection.active %}[Active]{% endif %} {% if collection.publish_results %}[Results Published]{% endif %}</li> <!-- Placeholder for any future quick actions -->
</div>
{% endfor %} </div>
</div> </div>
{% endif %} </div>
</div> </div>
{% endblock %} {% endblock %}