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