numerous supervisor upgrades

This commit is contained in:
Ross
2024-10-28 16:30:13 +00:00
parent 375261dd84
commit a2907fa285
21 changed files with 455 additions and 27 deletions
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0023_exam_exam_open_access'),
]
operations = [
migrations.AddField(
model_name='exam',
name='results_supervisor_visible',
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0062_alter_casecollection_markers'),
]
operations = [
migrations.AddField(
model_name='casecollection',
name='results_supervisor_visible',
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
),
]
+1
View File
@@ -201,6 +201,7 @@ class ExamFormMixin:
"active",
"publish_results",
"archive",
"results_supervisor_visible"
# "cid_user_groups",
# "user_user_groups",
# "author",
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0018_alter_supervisor_user'),
]
operations = [
migrations.AddField(
model_name='examuserstatus',
name='share_with_supervisor',
field=models.BooleanField(default=False, help_text='If true the exam status and results will be available to a users supervisor'),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.0.2 on 2024-10-28 13:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generic', '0019_examuserstatus_share_with_supervisor'),
]
operations = [
migrations.RemoveField(
model_name='examuserstatus',
name='share_with_supervisor',
),
migrations.AddField(
model_name='ciduserexam',
name='share_with_supervisor',
field=models.BooleanField(default=False, help_text='If true the exam status and results will be available to a users supervisor'),
),
]
+7
View File
@@ -602,6 +602,11 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
default=False,
)
results_supervisor_visible = models.BooleanField(
help_text="If true the results will be visible to supervisors without candidate approval.",
default=False,
)
class Meta:
abstract = True
@@ -1463,6 +1468,8 @@ class CidUserExam(models.Model):
# TODO switch to json field?
results_emailed_status = models.CharField(max_length=255, blank=True)
share_with_supervisor = models.BooleanField(default=False, help_text="If true the exam status and results will be available to a users supervisor")
def __str__(self) -> str:
if self.cid_user is None:
try:
+1
View File
@@ -21,6 +21,7 @@
<a href="{% url 'generic:manage_cid_exams' %}">Cids (exams)</a> /
<a href="{% url 'generic:cid_group_view' %}">Cid Groups</a> /
<a href="{% url 'generic:user_group_view' %}">User Groups</a> /
<a href="{% url 'trainees' %}">Trainees</a> /
<a href="{% url 'accounts_list' %}">Manage Users</a> /
<a href="{% url 'generic:supervisor' %}">Manage Supervisors</a> /
<a href="{% url 'generic:examcollection_list' %}">Collections</a>
+23
View File
@@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% block title %}
Supervisor
{% endblock %}
{% block css %}
{% endblock %}
{% block js %}
{% endblock %}
{% block content %}
{% endblock %}
{% block navigation %}
Supervisor:
{% if request.user.is_authenticated %}
<a href='{% url "generic:supervisor_overview" supervisor.pk %}'>Overview</a> /
{% endif %}
{% endblock %}
@@ -113,6 +113,7 @@ Open access: {{ exam.open_access }}<br />
Author(s): {% for author in exam.author.all %}
{{ author }}{% if not forloop.last %}, {% endif %}
{% endfor %}<br/>
<span title="If true the results from this exam will be visible to supervisors automatically. If false the trainee will have to manually approve access.">Supervisor visible: {{exam.results_supervisor_visible}}</span><br/>
<details>
<summary>Extra</summary>
@@ -0,0 +1,43 @@
{% extends 'generic/base_supervisor.html' %}
{% load crispy_forms_tags %}
{% block content %}
<h2>Supervisor: {{supervisor.name}}</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>As a supervisor you have the ability to view results on the platform for your associated trainees.</p>
<p>Some exams / collection results will be automatically available for you to review, others may require the trainee to allow access</p>
<p>If your trainees are incorrect please contact ross.kruger@nhs.net</p>
</details>
<h4>Trainees</h4>
You have the following trainee(s):
<ul id="trainee-list">
{% for trainee in trainees %}
<li>
<i class="bi bi-file-person"></i>
<a href='{% url "generic:supervisor_trainee" supervisor.pk trainee.pk %}'>{{trainee.first_name}} {{trainee.last_name}}, {{trainee.email}}</a>
</li>
{% endfor %}
</ul>
{% endblock %}
{% block css %}
<style>
#trainee-list {
list-style-type: none;
padding: 0;
}
</style>
{% endblock css %}
@@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block css %}
{% endblock %}
{% block content %}
This form allows you to create an account if you are a supervisor. To do so please enter your email address.
<form>
<input type="text" name="email" id="email" value="" required>
<button
hx-post="{% url 'generic:supervisor_request_account' %}"
hx-target="#result"
>Request Account</button>
</form>
<div id="result"></div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends 'generic/base_supervisor.html' %}
{% load crispy_forms_tags %}
{% block css %}
{% endblock %}
{% block content %}
<h2>Trainee: {{trainee.first_name}} {{trainee.last_name}}</h2>
email: {{trainee.email}}
<h3>Results</h3>
<div id="exam-results">
{% if all_exams %}
The following exam results for the trainee are available.
{% for exam_type, exams in all_exams %}
{% if exams %}
<h4>{{exam_type|title}}</h4>
<ul class='{{exam_type|lower}}'>
{% for exam in exams %}
<li class="exam" data-exam-id="{{exam.pk}}">
<a href= "{% url exam_type|add:':exam_scores_user_supervisor' pk=exam.pk user_id=trainee.pk %}" title="Click to view results">
{{exam}}</a> {% if exam.active %}[Active]{% endif %} {% if exam.publish_results %}[Results Published]{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% else %}
No results are available for this trainee.
{% endif %}
</div>
{% endblock %}
+16
View File
@@ -166,6 +166,17 @@ urlpatterns = [
path("cids/<int:cid>/details", views.cid_details, name="cid_details"),
path("cids/create/email", views.create_cid_email, name="create_cid_email"),
path("supervisor", views.SupervisorList.as_view(), name="supervisor"),
path("supervisor/request_account", views.supervisor_request_account, name="supervisor_request_account"),
path(
"supervisor/<int:pk>/overview",
views.supervisor_overview,
name="supervisor_overview",
),
path(
"supervisor/<int:pk>/trainee/<int:trainee_id>/",
views.supervisor_trainee,
name="supervisor_trainee",
),
path(
"supervisor/<int:pk>",
views.SupervisorDetail.as_view(),
@@ -342,6 +353,11 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
generic_exam_view.exam_scores_user_admin,
name="exam_scores_user_admin",
),
path(
"exam/<int:pk>/scores/<int:user_id>/user/supervisor",
generic_exam_view.exam_scores_user_supervisor,
name="exam_scores_user_supervisor",
),
path(
"exam/<int:pk>/toggle_active",
generic_exam_view.exam_toggle_active,
+136 -6
View File
@@ -98,12 +98,14 @@ from rapids.models import (
ExamQuestionDetail as RapidsExamQuestionDetail,
)
from rapids.models import Exam as RapidsExam
from rapids.models import UserAnswer as RapidsUserAnswer
from longs.models import (
Long as LongQuestion,
LongSeries,
ExamQuestionDetail as LongsExamQuestionDetail,
)
from longs.models import Exam as LongsExam
from longs.models import UserAnswer as LongsUserAnswer
from anatomy.models import (
AnatomyQuestion as AnatomyQuestion,
ExamQuestionDetail as AnatomyExamQuestionDetail,
@@ -113,12 +115,15 @@ from sbas.models import (
Question as SbasQuestion,
ExamQuestionDetail as SbasExamQuestionDetail,
)
from anatomy.models import UserAnswer as AnatomyUserAnswer
from sbas.models import Exam as SbasExam
from sbas.models import UserAnswer as SbasUserAnswer
from physics.models import (
Question as PhysicsQuestion,
ExamQuestionDetail as PhysicsExamQuestionDetail,
)
from physics.models import Exam as PhysicsExam
from physics.models import UserAnswer as PhysicsUserAnswer
from django.db.models import Case, When
from django.conf import settings
@@ -164,6 +169,34 @@ class CidManagerRequiredMixin(UserPassesTestMixin):
# raise PermissionDenied() # or Http404
def get_user_exams(user, supervisor_view=False):
EXAM_ANSWER_MAP = {
"physics": (PhysicsUserAnswer, PhysicsExam),
"anatomy": (AnatomyUserAnswer, AnatomyExam),
"rapids": (RapidsUserAnswer, RapidsExam),
"longs": (LongsUserAnswer, LongsExam),
"sbas": (SbasUserAnswer, SbasExam),
}
kwargs = {
"exam_mode": True,
"archive": False
}
if supervisor_view:
kwargs["results_supervisor_visible"] = True
exams = []
for exam_type in EXAM_ANSWER_MAP:
UserAnswer, Exam = EXAM_ANSWER_MAP[exam_type]
exam_answers = UserAnswer.objects.filter(user=user)
exam_ids = exam_answers.values_list("exam").distinct()
exams_to_add = Exam.objects.filter(id__in=exam_ids, **kwargs)
if exams_to_add:
exams.append((exam_type, exams_to_add))
return exams
def get_exam_model_from_app_name(app_name: str) -> ExamBase:
EXAM_MAP = {
"physics": PhysicsExam,
@@ -494,6 +527,9 @@ class ExamViews(View, LoginRequiredMixin):
if (exam.open_access and exam.active) or user in exam.get_author_objects():
return True
if exam.cid_user_exam.filter(user_user=user):
return True
if marker and user in exam.markers.all():
return True
@@ -1521,7 +1557,9 @@ class ExamViews(View, LoginRequiredMixin):
exams = self.Exam.objects.all()
filter = self.ExtraExamFilter(request.GET, queryset=exams)
else:
exams = self.Exam.objects.filter(author__id=request.user.id) | self.Exam.objects.filter(open_access=True)
exams = self.Exam.objects.filter(
author__id=request.user.id
) | self.Exam.objects.filter(open_access=True)
filter = self.BasicExamFilter(request.GET, queryset=exams)
return render(
@@ -2044,21 +2082,38 @@ class ExamViews(View, LoginRequiredMixin):
user = User.objects.get(id=user_id)
return self.exam_scores_cid_user(request, pk, user=user)
def exam_scores_user_supervisor(self, request, pk, user_id):
user = User.objects.get(id=user_id)
if not request.user.supervisor == user.userprofile.supervisor:
raise PermissionDenied
return self.exam_scores_cid_user(request, pk, user=user, supervisor=request.user)
@method_decorator(user_passes_test(lambda u: u.is_superuser))
def exam_scores_cid_user_admin(self, request, pk, cid):
user = CidUser.objects.get(cid=cid)
return self.exam_scores_cid_user(request, pk, cid, user.passcode)
def exam_scores_cid_user(self, request, pk, cid=None, passcode="", user=None):
def exam_scores_cid_user(self, request, pk, cid=None, passcode="", user=None, supervisor=None):
exam: ExamBase = get_object_or_404(self.Exam, pk=pk)
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
if not exam.check_cid_user(cid, passcode, request.user):
if not self.check_user_access(request.user, pk):
# raise PermissionDenied
raise Http404("Error accessing exam")
if supervisor is None:
if not exam.check_cid_user(cid, passcode, request.user):
#TODO clear this up
# Should users be able to access results for exams they do not have access to (if they have results)?
if not self.check_user_access(request.user, pk):
# raise PermissionDenied
#if request.user.supervisor == user.userprofile.supervisor:
# pass
# check for supervisor access
raise Http404("Error accessing exam")
else:
if user is None:
raise Exception("user must be defined")
if user is not None:
request.user = user
@@ -2176,6 +2231,8 @@ class ExamViews(View, LoginRequiredMixin):
case _:
max_score = len(questions)
cid_user_exam = exam.cid_user_exam.filter(user_user=user).first()
template_context = {
"exam": exam,
"cid": cid,
@@ -2187,6 +2244,8 @@ class ExamViews(View, LoginRequiredMixin):
"max_score": max_score,
"answers_and_marks": answers_and_marks,
"view_all_results": view_all_results,
"supervisor": supervisor,
"cid_user_exam": cid_user_exam,
}
if self.normalise_score is not None:
@@ -3754,3 +3813,74 @@ class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView):
if self.request.user in obj.get_author_objects():
return context
raise PermissionDenied() # or Http404
def supervisor_overview(request, pk):
supervisor = get_object_or_404(Supervisor, pk=pk)
if not request.user.supervisor == supervisor:
raise PermissionDenied()
trainees = User.objects.filter(userprofile__supervisor=supervisor)
return render(
request,
"generic/supervisor_overview.html",
{"supervisor": supervisor, "trainees": trainees},
)
def supervisor_trainee(request, pk, trainee_id):
supervisor = get_object_or_404(Supervisor, pk=pk)
if not request.user.supervisor == supervisor:
raise PermissionDenied()
# We do this to check that the supervisor can (should) see the trainee results
trainee = User.objects.get(userprofile__supervisor=supervisor, pk=trainee_id)
exams = get_user_exams(trainee, supervisor_view=True)
print(trainee)
return render(
request,
"generic/supervisor_trainee.html",
{"supervisor": supervisor, "trainee": trainee, "all_exams": exams},
)
def supervisor_request_account(request):
if request.method == "POST":
email = request.POST.get("email")
try:
validate_email(email)
except ValidationError:
return HttpResponse(f"Invalid email address ({email})")
try:
supervisor = Supervisor.objects.get(email=email)
except Supervisor.DoesNotExist:
return HttpResponse(f"Invalid email address ({email})")
if supervisor.user:
return HttpResponse(format_html("User already exists. Reset your password <a href='{}'>here</a>", reverse("password_reset")))
try:
user_account = User.objects.get(email=email)
except User.DoesNotExist:
try:
first, last = supervisor.name.split(" ", 1)
except ValueError:
first, last = supervisor.name, ""
user_account = User.objects.create_user(email=email, username=email, password=secrets.token_hex(nbytes=16), first_name=first, last_name=last)
supervisor.user = user_account
supervisor.save()
return HttpResponse(format_html("Account created, you now need to reset your password <a href='{}'>here</a>", reverse("password_reset")))
else:
return render(request, "generic/supervisor_request_account.html", {})
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('longs', '0030_exam_exam_open_access'),
]
operations = [
migrations.AddField(
model_name='exam',
name='results_supervisor_visible',
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('physics', '0016_exam_exam_open_access'),
]
operations = [
migrations.AddField(
model_name='exam',
name='results_supervisor_visible',
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
),
]
+7 -21
View File
@@ -7,7 +7,7 @@ from atlas.models import CaseCollection, CidReportAnswer
from generic.decorators import user_is_cid_user_manager
from generic.filters import UserUserFilter
from generic.tables import UserUserTable
from generic.views import CidManagerRequiredMixin, RedirectMixin, get_question_and_content_type
from generic.views import CidManagerRequiredMixin, RedirectMixin, get_question_and_content_type, get_user_exams
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import csrf_exempt
@@ -39,9 +39,8 @@ from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from physics.models import UserAnswer as PhysicsUserAnswer, Question
from physics.models import UserAnswer as PhysicsUserAnswer
from physics.models import Exam as PhysicsExam
from physics.models import Question as SbasQuestion
from anatomy.models import UserAnswer as AnatomyUserAnswer
from anatomy.models import Exam as AnatomyExam
@@ -60,7 +59,6 @@ from longs.models import Long
from sbas.models import UserAnswer as SbasUserAnswer
from sbas.models import Exam as SbasExam
from sbas.models import Question as SbasQuestion
from anatomy.views import GenericExamViews as AnatomyExamViews
from rapids.views import GenericExamViews as RapidsExamsViews
@@ -148,6 +146,7 @@ def user_scores_admin(request, user_id):
return user_scores(request, user=user)
@login_required
def user_scores(request, user=None):
# exam = get_object_or_404(Exam, pk=pk)
@@ -164,23 +163,7 @@ def user_scores(request, user=None):
user = request.user
admin = False
EXAM_ANSWER_MAP = {
"physics": (PhysicsUserAnswer, PhysicsExam),
"anatomy": (AnatomyUserAnswer, AnatomyExam),
"rapids": (RapidsUserAnswer, RapidsExam),
"longs": (LongsUserAnswer, LongsExam),
"sbas": (SbasUserAnswer, SbasExam),
}
kwargs = {"exam_mode": True, "archive": False}
exams = []
for exam_type in EXAM_ANSWER_MAP:
UserAnswer, Exam = EXAM_ANSWER_MAP[exam_type]
exam_answers = UserAnswer.objects.filter(user=user)
exam_ids = exam_answers.values_list("exam").distinct()
exams_to_add = Exam.objects.filter(id__in=exam_ids, **kwargs)
if exams_to_add:
exams.append((exam_type, exams_to_add))
exams = get_user_exams(user)
# case_collections = request.user.casecollection_exams.all()
@@ -737,6 +720,9 @@ def accounts_bulk_create_check(request):
user = json.loads(request.POST.get("user"))
if "email" not in user:
return HttpResponse(format_html("<span class='error'>{}</span>", "No email provided"))
if User.objects.filter(username=user["email"]).exists():
return HttpResponse(format_html("<span class='error'>{}</span>", "User already exists"))
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rapids', '0015_alter_rapid_normal'),
]
operations = [
migrations.AddField(
model_name='exam',
name='results_supervisor_visible',
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-28 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sbas', '0016_exam_exam_open_access'),
]
operations = [
migrations.AddField(
model_name='exam',
name='results_supervisor_visible',
field=models.BooleanField(default=False, help_text='If true the results will be visible to supervisors without candidate approval.'),
),
]
+3
View File
@@ -32,6 +32,9 @@
{% endif %}
{% if request.user.supervisor %}
<a href='{% url "generic:supervisor_overview" request.user.supervisor.pk %}'>Supervisor overview</a>
{% endif %}
<p>
<a href="{% url 'user_scores'%}"><button>Exams</button></a>
+10
View File
@@ -1,5 +1,6 @@
{% if view_all_results %}
<div class="alert alert-info" role="alert">
Exam state:
@@ -24,6 +25,15 @@
<div class="alert alert-info" role="alert">Results are not currently published. You can see your own answers below.</a></div>
{% endif %}
{% endif %}
{% if supervisor %}
<div class="alert alert-info" role="alert">Viewing results as a <a href='{% url "generic:supervisor_overview" supervisor.supervisor.pk %}'>supervisor</a>.</div>
{% endif %}
<h2>Exam: {{ exam }}</h2>
<h3>Candidate: {{ cid|default_if_none:request.user.username }}</h3>
{% if exam.results_supervisor_visible %}
Exam results are visible to supervisor.<br/>
{% else %}
Share results with supervisor: {{cid_user_exam.share_with_supervisor}}<br/>
{% endif %}
Answers: