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
FROM python:3.12.1-slim
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# set work directory
+41 -6
View File
@@ -1,14 +1,49 @@
{% extends 'atlas/base.html' %}
{% 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>
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.<br/>
<a href="{% url 'atlas:user_uploads' %}"><i class="bi bi-cloud-arrow-up"></i> Uploads awaiting import</a>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">
<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 %}
+3 -2
View File
@@ -8,7 +8,7 @@
<div class="container-fluid">
<h2>My Collections</h2>
<details>
{% comment %} <details>
<summary>
<h4>Filter</h4>
</summary>
@@ -18,9 +18,10 @@
<input class="btn btn-primary btn-sm mt-1 mb-1" type="submit" />
</form>
</div>
</details> {% endcomment %}
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</details>
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div>
<div id="exam-options"></div>
+2
View File
@@ -54,4 +54,6 @@ services:
volumes:
postgres_data:
name: docker_postgres_data
pgadmin_data:
name: docker_pgadmin_data
+45
View File
@@ -1,4 +1,6 @@
import django_filters
from django.db.models import Q
from collections import OrderedDict
from django.contrib.auth.models import User
@@ -34,6 +36,8 @@ class CidUserFilter(django_filters.FilterSet):
return parent
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__supervisor = django_filters.ModelChoiceFilter(queryset=Supervisor.objects.all(), label="Supervisor")
userprofile__peninsula_trainee = django_filters.BooleanFilter(label="Peninsula Trainee")
@@ -69,6 +73,47 @@ class UserUserFilter(django_filters.FilterSet):
super(UserUserFilter, self).__init__(*args, **kwargs)
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 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 django.contrib.auth.mixins import LoginRequiredMixin
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.http import Http404, HttpRequest
from django.shortcuts import render
@@ -1859,8 +1861,16 @@ class Supervisor(models.Model):
return reverse("generic:supervisor_detail", kwargs={"pk": self.pk})
def clean(self):
self.email = self.email.strip()
self.name = self.name.strip()
if self.email:
# 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)
+15 -15
View File
@@ -151,7 +151,7 @@ class FirstImageColumn(tables.Column):
return format_html('<span title="{}">Invalid image url<span>', image_object)
class CidUserTable(tables.Table):
class CidUserTable(SelectionTable):
cid = tables.LinkColumn(
"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")
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)
@@ -171,7 +171,7 @@ class CidUserTable(tables.Table):
"generic:update_cid", text="Edit", args=[A("pk")], orderable=True
)
class Meta:
class Meta(SelectionTable.Meta):
model = CidUser
template_name = "django_tables2/bootstrap4.html"
fields = (
@@ -199,7 +199,7 @@ class CidUserTable(tables.Table):
return format_html(record.email)
class CidUserExamTable(tables.Table):
class CidUserExamTable(SelectionTable):
cid = tables.LinkColumn(
"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")
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)
@@ -218,7 +218,7 @@ class CidUserExamTable(tables.Table):
"generic:update_cid", text="Edit", args=[A("pk")], orderable=True
)
class Meta:
class Meta(SelectionTable.Meta):
model = CidUser
template_name = "django_tables2/bootstrap4.html"
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)
class UserUserTable(tables.Table):
class UserUserTable(SelectionTable):
user = tables.Column(
linkify={"viewname": "account_profile", "args": [A("username")]},
@@ -275,7 +275,7 @@ class UserUserTable(tables.Table):
accessor=A("userprofile__supervisor"),
)
selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
#selection = tables.CheckBoxColumn(accessor="pk", orderable=False)
edit = tables.Column(
empty_values=(),
@@ -294,7 +294,7 @@ class UserUserTable(tables.Table):
date_joined = tables.DateTimeColumn(format="Y/m/d")
class Meta:
class Meta(SelectionTable.Meta):
model = User
template_name = "django_tables2/bootstrap4.html"
fields = (
@@ -348,7 +348,7 @@ class UserUserTable(tables.Table):
record.email,
)
class ExaminationTable(tables.Table):
class ExaminationTable(SelectionTable):
examination = tables.Column(
linkify=("generic:examination_detail", {"pk": tables.A("pk")}),
verbose_name="Condition",
@@ -360,19 +360,19 @@ class ExaminationTable(tables.Table):
delete = tables.LinkColumn(
"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.Column(empty_values=())
#parent = tables.ManyToManyColumn(verbose_name="Parents")
class Meta:
class Meta(SelectionTable.Meta):
model = Examination
template_name = "django_tables2/bootstrap4.html"
#fields = ("primary", "subspecialty")
sequence = ("examination",)
class SupervisorTable(tables.Table):
class SupervisorTable(SelectionTable):
name = tables.Column(
linkify=("generic:supervisor_detail", {"pk": tables.A("pk")}),
verbose_name="Name",
@@ -383,13 +383,13 @@ class SupervisorTable(tables.Table):
accessor="pk", orderable=False
)
class Meta:
class Meta(SelectionTable.Meta):
model = Supervisor
template_name = "django_tables2/bootstrap4.html"
sequence = ("name", "email")
exclude = ("id",)
attrs = {"id": "supervisor-table"}
SelectionTable.Meta.attrs.update({"id": "supervisor-table"})
def render_edit(self, value, record):
url = f"{reverse("generic:supervisor_edit", args=[record.pk])}?redirect={self.request.path}"
+1 -12
View File
@@ -7,23 +7,12 @@
{% 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>
<span id="manage-span">
{% render_table table %}
</span>
</div>
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
<details>
<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;
background: var(--bs-body-bg);
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 {
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>
<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 %}
{% comment %} <ul id="supervisor-list">
@@ -25,6 +13,7 @@
</li>
{% endfor %}
</ul> {% endcomment %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
{% endblock %}
+1 -13
View File
@@ -8,19 +8,6 @@
{% block content %}
<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">
<summary>
Bulk edit
@@ -71,6 +58,7 @@
{% render_table table %}
</span>
</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 'create_user' %}">Create single user</a>
+5
View File
@@ -182,6 +182,11 @@ urlpatterns = [
views.UserUserGroupCreate.as_view(),
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/<int:cid>/details", views.cid_details, name="cid_details"),
path("cids/create/email", views.create_cid_email, name="create_cid_email"),
+215 -59
View File
@@ -5,7 +5,7 @@ import statistics
import threading
from typing import Any
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.forms.models import model_to_dict
from django.db.models.functions import Coalesce, Lower
@@ -49,6 +49,7 @@ from reversion.views import RevisionMixin
from atlas.models import CaseCollection, CaseDetail
from generic.decorators import user_is_cid_user_manager
from generic.filters import CidUserFilter, ExaminationFilter, SupervisorFilter
from generic.models import UserUserGroup
from generic.tables import (
CidUserExamTable,
@@ -212,7 +213,8 @@ def bulk_delete_questions(request):
Model = APP_MODEL_MAP.get(app)
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
errors = []
@@ -222,7 +224,7 @@ def bulk_delete_questions(request):
qs.delete()
except Exception as e:
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
context = {"deleted": deleted, "errors": errors}
@@ -3084,6 +3086,21 @@ class GenericViewBase:
# Build base queryset of questions for this app
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
if category:
try:
@@ -3152,6 +3169,23 @@ class GenericViewBase:
# Build base queryset of questions for this app
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
if category:
try:
@@ -3166,6 +3200,20 @@ class GenericViewBase:
except Exception:
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
results = []
for question in qs:
@@ -3177,25 +3225,46 @@ class GenericViewBase:
except Exception:
pass
ct = ContentType.objects.get_for_model(question)
latest = (
QuestionReview.objects.filter(content_type=ct, object_id=question.pk)
.order_by("-created_on").first()
)
# Determine latest status — prefer annotated value if present
latest_status = getattr(question, "latest_review_status", None)
match = False
if status == "ANY":
match = True
elif status == "UNREVIEWED":
match = latest is None
match = latest_status is None
else:
if latest is not None and latest.status == status:
if latest_status is not None and latest_status == status:
match = True
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)
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))
@@ -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
@user_is_cid_user_manager
def group_email_resend(request, pk):
@@ -4369,7 +4510,6 @@ def trainees_bulk_update_from_spreadsheet(request):
# Fall back to automatic match
if not user:
user = row.get("matched_user")
if not user:
skipped += 1
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):
return create_user(request, context, trainee=True)
@@ -4713,12 +4809,17 @@ class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView):
# Do permission checks here
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()
or self.request.user.is_superuser
):
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
raise PermissionDenied() # or Http404
@@ -4902,3 +5003,58 @@ def cimar_case_refresh(request, uuid):
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;
}
a, a:link {
/* a, a:link {
color: #3282b8;
}
} */
.navbar .nav-link {
/* .navbar .nav-link {
color: #3282b8;
}
} */
span#user-id {
opacity: 50%;
+37 -3
View File
@@ -80,8 +80,10 @@ from generic.models import (
Supervisor,
UserGrades,
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
@@ -117,7 +119,23 @@ def people(request):
@login_required
def profile(request):
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):
@@ -141,7 +159,23 @@ def index(request):
@user_is_cid_user_manager
def account_profile(request, 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):
+35
View File
@@ -24,6 +24,8 @@ from django.forms.widgets import RadioSelect, TextInput, Textarea
from tinymce.widgets import TinyMCE
from dal import autocomplete
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Fieldset, Row, Column, Submit
class UserAnswerForm(ModelForm):
@@ -105,6 +107,39 @@ class QuestionForm(ModelForm):
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):
# Get the unsaved Pizza instance
instance = ModelForm.save(self, False)
+1 -1
View File
@@ -96,7 +96,7 @@
<div class="mb-3">
<h5 class="h6">Feedback</h5>
<div>{{ question.feedback|linebreaks }}</div>
<div>{{ question.feedback|safe|linebreaks }}</div>
</div>
<div class="mb-3">
+28 -13
View File
@@ -2,6 +2,7 @@
{% load static %}
{% load partials %}
{% load crispy_forms_tags %}
{% block js %}
<!-- <script type="text/javascript" src="/admin/jsi18n/"></script> -->
@@ -11,19 +12,33 @@
{{ form.media }}
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-start mb-3 flex-column flex-md-row">
<div>
<h2>Submit Question</h2>
<div class="container py-3">
<div class="d-flex justify-content-between align-items-start mb-3 flex-column flex-md-row">
<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>
{% if question %}
{% include 'sbas/question_detail.html#links-partial' %}
{% endif %}
</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 %}
+23 -1
View File
@@ -1,10 +1,22 @@
{% extends 'sbas/base.html' %}
{% load partials %}
{% block content %}
<h2>Filtered questions</h2>
<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">
Active filters:
{% if filters.category %}
@@ -24,8 +36,18 @@
{% for q in questions %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<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>
{% 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 class="btn-group">
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' q.pk %}">View</a>
@@ -25,6 +25,8 @@
</div>
<button type="submit" class="btn btn-primary">Start review</button>
<div class="mb-3">
@@ -40,7 +42,13 @@
<button id="view-list-btn" type="button" class="btn btn-outline-primary">View list</button>
</div>
</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>
// When View list is clicked, read the values from the main filter form and navigate
@@ -51,8 +59,10 @@
const params = new URLSearchParams();
const categoryEl = document.getElementById('id_category');
const statusEl = document.getElementById('id_status');
const reviewedEl = document.getElementById('id_reviewed_me');
if (categoryEl && categoryEl.value) params.set('category', categoryEl.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()) : '');
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)
# Simple permission: allow staff or users with change permission
if not (request.user.is_staff or request.user.has_perm('sbas.change_question')):
return HttpResponseForbidden("Forbidden")
if question.is_author(request.user):
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
if request.method == "POST":
+85 -2
View File
@@ -157,14 +157,14 @@
users.push(user)
emails.push(user.email)
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>
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="last_name" value="${user.last_name}" hidden>
<input type="text" name="email" value="${user.email}" 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_email" value="${user.supervisor_email}" hidden>
<button
hx-post="{% url 'accounts_bulk_create_check' %}"
hx-trigger="load"
@@ -186,21 +186,104 @@
// Make sure the new hyperscript works
_hyperscript.processNode($("#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));
$("#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
//document.body.appendChild(myTable);
document.addEventListener('htmx:afterRequest', function(evt) {
updateWarnings();
updateSupervisorInputs();
});
}
</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">
def updateWarnings()
log "test"
+3 -2
View File
@@ -201,7 +201,7 @@
<a class="nav-link active" href="{% url 'atlas:index' %}">Atlas</a>
</li>
<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
</a>
<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
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;
}
@@ -374,7 +375,7 @@ document.addEventListener('DOMContentLoaded', function() {
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){
if (t.querySelector && (t.querySelector('input[name="selection"]') || t.querySelector('input[type="checkbox"]'))) {
ensureControlsForTable(t);
+111 -77
View File
@@ -1,92 +1,126 @@
{% extends 'base.html' %}
{% load auth_extras %}
{% load help_tags %}
{% block content %}
<h1>PENRA Courses</h1>
{% if not request.user.is_authenticated %}
<a href="{% url 'login'%}">Log in</a><br/>
<a href="{% url 'password_reset'%}">Reset password</a>
{% else %}
{% comment %} <a href="{% url 'atlas:index'%}">Atlas</a>
<a href="{% url 'anatomy:index'%}">Anatomy</a>
<a href="{% url 'physics:index'%}">Physics</a>
<a href="{% url 'rapids:index'%}">Rapids</a>
<a href="{% url 'longs:index'%}">Longs</a>
<a href="{% url 'sbas:index'%}">SBAs</a> {% endcomment %}
{% endif %}
{% if request.user.is_authenticated %}
{% if request.user.userprofile.peninsula_trainee %}
<div id="user-details card">
<div class="card-body">
<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 class="py-4">
<div class="container">
<div class="row mb-4">
<div class="col-12">
<div class="p-4 bg-dark rounded-3 border">
<div class="d-flex justify-content-between align-items-center">
<h1 class="mb-0">PENRA Courses</h1>
{% if not request.user.is_authenticated %}
{% comment %} <div>
<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> {% endcomment %}
{% else %}
<a class="btn btn-outline-light" href="{% url 'profile' %}">Your profile</a>
{% endif %}
</div>
<p class="text-muted mt-2">Access exams, courses and resources for trainees and trainers.</p>
</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 %}
<a href='{% url "generic:supervisor_overview" request.user.supervisor.pk %}'>Supervisor overview</a>
{% endif %}
<div class="card mb-3">
<div class="card-body">
<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>
<p>
View your courses / exams <a href="{% url 'user_scores'%}">here</a>
{% if rcr_assessor %}
<div class="alert alert-info" role="alert">
<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>
{% endif %}
<div class="col-lg-4">
<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 %}
<h2>Exam collections</h2>
<ul>
{% for collection in collections %}
<li><a href="{% url 'generic:examcollection_detail' collection.pk %}">{{collection.name}}</a></li>
{% endfor %}
</ul>
{% 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 class="row mt-4">
<div class="col-12 text-center">
{% if request.user.is_authenticated %}
{% 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 %}
{% endif %}
</div>
</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>
{% if request.user.is_superuser %}
<a href="{% url 'django_psutil_dash:dashboard' %}">psutil</a>
{% endif %}
{% endblock %}
+122 -58
View File
@@ -2,67 +2,131 @@
{% load auth_extras %}
{% block content %}
<div class="anatomy">
<h1>Profile</h1>
<div>
Username: <span id="username">{{ user.username }}</span>
</div>
<div>
Email: <span id="email">{{ user.email }}</span>
</div>
<div>
Name: <span id="first-name">{{ user.first_name }}<span>
<span id="last-name">{{ user.last_name }}</span>
</div>
<div>
Grade: {{ user.userprofile.grade }}
</div>
<div>
Supervisor: {{ user.userprofile.supervisor }}
{% if request.user.is_superuser and user.userprofile.supervisor %}
<a href='{% url "generic:supervisor_detail" user.userprofile.supervisor.pk %}'><i class="bi bi-link"></i></a>
{% endif %}
<br/>
</div>
<div>
Registration number: {{ user.userprofile.registration_number }}
</div>
<div class="container py-4">
<div class="row">
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-body text-center">
<div class="profile-avatar mb-3">
<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>
<h4 class="card-title mb-1">{{ user.get_full_name|default:user.username }}</h4>
<p class="text-muted mb-1">@{{ user.username }}</p>
<p class="mb-0"><a href="mailto:{{ user.email }}">{{ user.email }}</a></p>
<div class="mt-2">
<small class="text-muted">Permission groups:</small>
<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 %}
<h3>Groups</h3>
<ul>
{% for group in user.user_groups.all %}
<li><a href="{% url 'generic:user_group_detail' group.pk %}">{{group}}</a></li>
{% empty %}
<li>No groups.</li>
{% endfor %}
</ul>
<h3>Exams</h3>
<p>
{% with user.userprofile.get_exams as exam_map %}
{% for exam_type, exams in exam_map.items %}
<h4>{{exam_type}}</h4>
<ul>
{% for exam in exams %}
<li>
<a href="{{exam.get_absolute_url}}">{{exam}}</a>
</li>
{% endfor %}
{% 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>
</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>
{% empty %}
No exams.
{% endfor %}
{% endwith %}
</p>
<div class="card-body">
<a class="btn btn-outline-primary d-block mb-2" href="{% url 'password_change' %}">Change password</a>
{% if request.user|has_group:"cid_user_manager" %}
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
{% 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>
{% if request.user|has_group:"cid_user_manager" %}
<a href="{% url 'account_update' user.username %}">Update user details</a>
{% endif %}
<a id="update-profile-link" href="{% url 'account_profile_update' user.username %}">Update user profile</a>
<div class="card">
<div class="card-header">Exams</div>
<div class="card-body">
{% with user.userprofile.get_exams as exam_map %}
{% if exam_map %}
{% 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>
{% 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="mb-2 mb-md-0">
<div class="btn-toolbar" role="toolbar">
{% if user.is_superuser %}
<div class="btn-group me-2" role="group">
<button id="delete-selected-btn" class="btn btn-danger btn-sm"
hx-post="{% url 'generic:bulk_delete_questions' %}"
@@ -19,6 +20,7 @@
hx-swap="innerHTML"
hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">Delete selected</button>
</div>
{% endif %}
<div class="btn-group" role="group">
<button id="button-select-add-exam" class="btn btn-outline-primary btn-sm"
hx-get="{% url 'generic:generic_exam_htmx' %}?type={{ app_name }}"
+111 -66
View File
@@ -1,78 +1,123 @@
{% extends 'base.html' %}
{% load help_tags %}
{% block content %}
<div class="">
<h2>User: {{ cid_user.username }}</h2>
<div class="alert alert-dark" role="alert">
<details>
<summary>Help</summary>
<p>This page shows the exam(s) to which you have access.</p>
<p>When an exam is available to take it will have a "Start" button next to it.</p>
<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>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>
</details>
<div class="container py-4">
<div class="row mb-3">
<div class="col-md-8">
<h2 class="mb-1">User: {{ cid_user.username }}</h2>
{% help "Help" %}
<div class="card mt-2">
<div class="card-body small">
<p class="mb-1">This page shows the exam(s) to which you have access.</p>
<p class="mb-1">When an exam is available to take it will have a "Start" button next to it.</p>
<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>
<h3>Assigned Exams</h3>
<div id="exam-assigned">
The following exams will be available to take when active.
{% for exam_type, exams in available_exams %}
{% if exams %}
<h4>{{exam_type|title}}</h4>
<ul class='{{exam_type|lower}}'>
{% for exam in exams %}
<li class="exam" data-exam-id="{{exam.pk}}">{{exam}}
{% if exam.active %}
<a href="{{exam.get_take_url}}" target="_blank" title="Click to take exam">
<button class="small-url-button start-button">Start</button>
</a>
{% endif %}
{% if exam.publish_results %}
<a href= "{% url exam_type|lower|add:':exam_scores_user' pk=exam.pk %}" title="Click to view results">
<button class="small-url-button results-button">Results Published</button>
</a>
{% endif %}
</li>
<div class="row">
<div class="col-lg-8">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Assigned Exams</h5>
</div>
<div class="card-body">
<p class="text-muted">The following exams will be available to take when active.</p>
{% for exam_type, exams in available_exams %}
{% if exams %}
<h6 class="mt-3">{{ exam_type|title }}</h6>
<ul class="list-group">
{% for exam in exams %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
{{ exam }}
{% 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="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 %}
</ul>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% if all_exams %}
<h3>Exam Results</h3>
<div id="exam-results">
The following exam results been found. Click to view answers (and scores when the results are published):
{% for exam_type, exams in all_exams %}
{% if exams %}
<h4>{{exam_type|title}}</h4>
<ul class='{{exam_type|lower}}'>
{% for exam in exams %}
<li class="exam" data-exam-id="{{exam.pk}}">
{% if admin %}
<a href= "{% url exam_type|add:':exam_scores_user_admin' pk=exam.pk user_id=user.pk %}" title="Click to view results">
{% else %}
<a href= "{% url exam_type|add:':exam_scores_user' pk=exam.pk %}" title="Click to view results">
{% endif %}
{{exam}}</a> {% if exam.active %}[Active]{% endif %} {% if exam.publish_results %}[Results Published]{% endif %}</li>
{% if all_exams %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Exam Results</h5>
</div>
<div class="card-body">
<p class="text-muted">Click an exam to view answers (and scores when results are published).</p>
{% for exam_type, exams in all_exams %}
{% if exams %}
<h6 class="mt-3">{{ exam_type|title }}</h6>
<ul class="list-group">
{% for exam in exams %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
{% if admin %}
<a href="{% url exam_type|add:':exam_scores_user_admin' pk=exam.pk user_id=user.pk %}">{{ exam }}</a>
{% else %}
<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 %}
</ul>
{% endif %}
{% endfor %}
</div>
</div>
{% 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>
{% endif %}
{% if case_collections %}
<div>
<h3>Case Collection</h3>
The following Case Collections have been found.
{% for collection in case_collections %}
<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>
{% endfor %}
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Actions</h6>
<p class="small text-muted">Quick links and information.</p>
<!-- Placeholder for any future quick actions -->
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}