Compare commits
28
Commits
d4aa3e73e2
...
d3669e8316
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3669e8316 | ||
|
|
b92a969a3b | ||
|
|
c45d7a5464 | ||
|
|
b08154f804 | ||
|
|
6d02b35f86 | ||
|
|
3a5ceabff0 | ||
|
|
3461e45f93 | ||
|
|
eec04e3197 | ||
|
|
68dc7c05d8 | ||
|
|
ff02e3f02f | ||
|
|
3db74607ce | ||
|
|
37eae568f0 | ||
|
|
772e852763 | ||
|
|
d3a39d11a5 | ||
|
|
9cc4ea3cb0 | ||
|
|
d93483db26 | ||
|
|
aff8d205ed | ||
|
|
e58f070ae2 | ||
|
|
7a00efdf39 | ||
|
|
e173427d84 | ||
|
|
3c1bd40ce6 | ||
|
|
0679a0ec13 | ||
|
|
5d91b4a4ff | ||
|
|
83a60203f7 | ||
|
|
2d0098b2b6 | ||
|
|
66f9dadcc8 | ||
|
|
7d079e0466 | ||
|
|
3ea384542d |
+1
-1
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -54,4 +54,6 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
pgadmin_data:
|
||||
name: docker_postgres_data
|
||||
pgadmin_data:
|
||||
name: docker_pgadmin_data
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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">×</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">×</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>
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -4901,4 +5002,59 @@ 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})
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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">×</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 %}
|
||||
@@ -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
@@ -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"> </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 %}
|
||||
|
||||
Reference in New Issue
Block a user