further improve anatomy questions

This commit is contained in:
Ross
2024-10-07 14:00:45 +01:00
parent 92663bc620
commit 3f5f39c41c
5 changed files with 344 additions and 130 deletions
+6
View File
@@ -329,6 +329,12 @@ class Answer(models.Model):
self.answer = self.answer.strip() self.answer = self.answer.strip()
self.answer_compare = get_answer_compare(self.answer) self.answer_compare = get_answer_compare(self.answer)
def can_edit(self, user):
"""Check if user can edit the answer
This just mirrors the result from the attached question"""
return self.question.can_edit(user)
# def get_compare_string(self): # def get_compare_string(self):
# s = self.answer.lower().strip() # s = self.answer.lower().strip()
# s = s.translate(str.maketrans('', '', string.punctuation)) # s = s.translate(str.maketrans('', '', string.punctuation))
+35 -3
View File
@@ -20,25 +20,40 @@
</div> </div>
<h2>Question type: {{question.question_type}}</h2> <h2>Question type: {{question.question_type}}</h2>
<h3>Primary answer: {{ question.get_primary_answer }}</h3> <h3>Primary answer: {{ question.get_primary_answer }}</h3>
<div>
<details> <details>
<summary> <summary title="Click to view the question answers">
Answers: Answers:
</summary> </summary>
<table> <table>
<tr><th>Answer</th><th>Score</th></tr> <tr><th>Answer</th><th>Score</th></tr>
{% for answer in question.answers.all|dictsortreversed:"status" %} {% for answer in question.answers.all|dictsortreversed:"status" %}
<tr> <tr>
<td> <td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
<span {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
{% endif %}> {% endif %}>
<span >
{{ answer }} {{ answer }}
</span> </span>
<td> <td>
<td>{{answer.status}}</td> <td>{{answer.status}}</td>
{% if answer.proposed %}
<td>
<button class="btn btn-sm accept-button" data-aid={{answer.id}}
hx-get="{% url 'anatomy:confirm_answer' answer.id %}"
title="Click to accept the proposed answer">Accept</button>
<button class="btn btn-sm delete-button" data-aid={{answer.id}}
hx-get="{% url 'anatomy:delete_answer' answer.id %}"
title="Click to delete the proposed answer"
>Delete</button>
</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</details> </details>
</div>
<div> <div>
Answer help: {{ question.answer_help|safe }} Answer help: {{ question.answer_help|safe }}
</div> </div>
@@ -52,6 +67,10 @@
Exams: {% for exam in question.exams.all %} Exams: {% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a> <a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a>
{% endfor %} {% endfor %}
<button class="btn btn-sm" hx-get="{% url 'anatomy:question_add_exam' question_id=question.pk %}"
hx-target="#exam-list"
hx-swap="innerHTML">Edit exam(s)</button>
<span id="exam-list"></span>
</div> </div>
<div> <div>
Modality: {{ question.modality }} Modality: {{ question.modality }}
@@ -149,3 +168,16 @@
} }
</script> </script>
{% endblock %} {% endblock %}
{% block css %}
<style>
.proposed-answer::before {
content: "\F505";
font-family: bootstrap-icons;
}
.question div {
margin-bottom: 10px;
}
</style>
{% endblock css %}
+7
View File
@@ -47,6 +47,13 @@ urlpatterns = [
views.QuestionDelete.as_view(), views.QuestionDelete.as_view(),
name="question_delete", name="question_delete",
), ),
path(
"question/<int:question_id>/add_exam",
views.question_add_exam,
name="question_add_exam",
),
path("answer/<int:answer_id>/confirm", views.confirm_answer, name="confirm_answer"),
path("answer/<int:answer_id>/delete", views.delete_answer, name="delete_answer"),
path("exam/<int:exam_pk>/<int:sk>/mark", views.mark, name="mark"), path("exam/<int:exam_pk>/<int:sk>/mark", views.mark, name="mark"),
path("exam/<int:exam_pk>/<int:sk>/mark/all", views.mark_all, name="mark_all"), path("exam/<int:exam_pk>/<int:sk>/mark/all", views.mark_all, name="mark_all"),
path( path(
+80
View File
@@ -27,6 +27,8 @@ from django.http import HttpResponseRedirect, HttpResponse
from dal import autocomplete from dal import autocomplete
from django.conf import settings from django.conf import settings
from django.utils.html import format_html_join
from django.utils.safestring import mark_safe
from .forms import ( from .forms import (
@@ -851,3 +853,81 @@ GenericViews = GenericViewBase("anatomy", AnatomyQuestion, UserAnswer, Exam)
class ExamClone(ExamCloneMixin, ExamCreate): class ExamClone(ExamCloneMixin, ExamCreate):
"""Clone exam view""" """Clone exam view"""
def confirm_answer(request, answer_id: int):
if request.htmx:
answer = get_object_or_404(Answer, pk=answer_id)
# Check if the user has permission to confirm the answer
if not answer.can_edit(request.user):
return HttpResponse("Invalid permissions", status=403)
answer.clean()
answer.proposed = False
answer.save()
return HttpResponse("Answer accepted")
raise PermissionDenied()
def delete_answer(request, answer_id: int):
if request.htmx:
answer = get_object_or_404(Answer, pk=answer_id)
# Check if the user has permission to confirm the answer
if not answer.can_edit(request.user):
return HttpResponse("Invalid permissions", status=403)
answer.delete()
return HttpResponse("Answer deleted")
raise PermissionDenied()
def question_add_exam(request, question_id: int):
if request.htmx:
question = get_object_or_404(AnatomyQuestion, pk=question_id)
if not question.can_edit(request.user):
return HttpResponse("Invalid permissions", status=403)
if request.POST:
exam_id = request.POST.get("exam_id", None)
if exam_id is None:
return HttpResponse("No exam id provided", status=400)
exam = get_object_or_404(Exam, pk=exam_id)
if request.POST.get("remove", False) == "true":
question.exams.remove(exam)
return HttpResponse("Question removed from exam.")
else:
question.exams.add(exam)
return HttpResponse("Question added to exam.")
else:
# Return a list of exams that we can add to the question
exams = Exam.objects.filter(author=request.user) | Exam.objects.filter(open_access=True)
exams = exams.difference(question.exams.all())
if not exams:
return HttpResponse("No exams available to add")
html = "Exams to add to question: <br>"
for exam in exams:
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><button hx-post=\"{request.path}\""
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<br>Exams to remove from question: <br>"
for exam in question.exams.all():
html = html + (f"<span id='htmx-exam-list'><form><input name='exam_id' value='{exam.id}' type='hidden'><input name='remove' value='true' type='hidden'><button hx-post=\"{request.path}\""
f"hx-confirm='Are you sure you want to remove this exam from the question?'"
f">{exam.name}: {exam.id}</button></form>"
)
html = html + "<button _='on click remove #htmx-exam-list'>Cancel</button>"
return HttpResponse(mark_safe(html))
raise PermissionDenied()
+158 -69
View File
@@ -48,7 +48,12 @@ 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.tables import CidUserExamTable, CidUserTable, ExaminationTable, SupervisorTable from generic.tables import (
CidUserExamTable,
CidUserTable,
ExaminationTable,
SupervisorTable,
)
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
import zipfile import zipfile
@@ -79,6 +84,7 @@ from .models import (
ExamCollection, ExamCollection,
ExamUserStatus, ExamUserStatus,
Examination, Examination,
QuestionBase,
QuestionNote, QuestionNote,
Supervisor, Supervisor,
UserGrades, UserGrades,
@@ -87,15 +93,31 @@ from .models import (
get_next_cid, get_next_cid,
) )
from rapids.models import Rapid as RapidQuestion, ExamQuestionDetail as RapidsExamQuestionDetail from rapids.models import (
Rapid as RapidQuestion,
ExamQuestionDetail as RapidsExamQuestionDetail,
)
from rapids.models import Exam as RapidsExam from rapids.models import Exam as RapidsExam
from longs.models import Long as LongQuestion, LongSeries, ExamQuestionDetail as LongsExamQuestionDetail from longs.models import (
Long as LongQuestion,
LongSeries,
ExamQuestionDetail as LongsExamQuestionDetail,
)
from longs.models import Exam as LongsExam from longs.models import Exam as LongsExam
from anatomy.models import AnatomyQuestion as AnatomyQuestion, ExamQuestionDetail as AnatomyExamQuestionDetail from anatomy.models import (
AnatomyQuestion as AnatomyQuestion,
ExamQuestionDetail as AnatomyExamQuestionDetail,
)
from anatomy.models import Exam as AnatomyExam from anatomy.models import Exam as AnatomyExam
from sbas.models import Question as SbasQuestion, ExamQuestionDetail as SbasExamQuestionDetail from sbas.models import (
Question as SbasQuestion,
ExamQuestionDetail as SbasExamQuestionDetail,
)
from sbas.models import Exam as SbasExam from sbas.models import Exam as SbasExam
from physics.models import Question as PhysicsQuestion, ExamQuestionDetail as PhysicsExamQuestionDetail from physics.models import (
Question as PhysicsQuestion,
ExamQuestionDetail as PhysicsExamQuestionDetail,
)
from physics.models import Exam as PhysicsExam from physics.models import Exam as PhysicsExam
from django.db.models import Case, When from django.db.models import Case, When
@@ -112,13 +134,13 @@ import plotly.express as px
from django.db.models import Prefetch from django.db.models import Prefetch
class RedirectMixin:
class RedirectMixin():
def get_success_url(self) -> str: def get_success_url(self) -> str:
if "redirect" in self.request.GET: if "redirect" in self.request.GET:
return self.request.GET["redirect"] return self.request.GET["redirect"]
return super().get_success_url() return super().get_success_url()
class AuthorRequiredMixin(object): class AuthorRequiredMixin(object):
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs) obj = super().get_object(*args, **kwargs)
@@ -141,6 +163,7 @@ class CidManagerRequiredMixin(UserPassesTestMixin):
# return obj # return obj
# raise PermissionDenied() # or Http404 # raise PermissionDenied() # or Http404
def get_exam_model_from_app_name(app_name: str) -> ExamBase: def get_exam_model_from_app_name(app_name: str) -> ExamBase:
EXAM_MAP = { EXAM_MAP = {
"physics": PhysicsExam, "physics": PhysicsExam,
@@ -687,13 +710,21 @@ class ExamViews(View, LoginRequiredMixin):
def exam_list(self, request, all=False): def exam_list(self, request, all=False):
if not self.check_user_access(request.user): if not self.check_user_access(request.user):
# raise PermissionDenied # raise PermissionDenied
exam_list = self.Exam.objects.filter(author__id=request.user.id, exam_mode=True).order_by( exam_list = self.Exam.objects.filter(
"name" author__id=request.user.id, exam_mode=True
) ).order_by("name")
exam_list = exam_list | self.Exam.objects.filter(markers__id=request.user.id, exam_mode=True).order_by("name") exam_list = exam_list | self.Exam.objects.filter(
markers__id=request.user.id, exam_mode=True
).order_by("name")
else: else:
exam_list = self.Exam.objects.prefetch_related("valid_user_users", "valid_cid_users").filter(exam_mode=True).order_by("name") exam_list = (
self.Exam.objects.prefetch_related(
"valid_user_users", "valid_cid_users"
)
.filter(exam_mode=True)
.order_by("name")
)
if not all: if not all:
exam_list = exam_list.filter(archive=False).order_by("name") exam_list = exam_list.filter(archive=False).order_by("name")
@@ -904,7 +935,7 @@ class ExamViews(View, LoginRequiredMixin):
queryset=self.Answer.objects.filter( queryset=self.Answer.objects.filter(
proposed=False, status=self.Answer.MarkOptions.CORRECT proposed=False, status=self.Answer.MarkOptions.CORRECT
), ),
to_attr="prefetched_primary_answer" to_attr="prefetched_primary_answer",
# queryset=self.Answer.objects.filter(), # queryset=self.Answer.objects.filter(),
), ),
) )
@@ -926,8 +957,7 @@ class ExamViews(View, LoginRequiredMixin):
# "seriesdetail", # "seriesdetail",
"series__images", "series__images",
"series__plane", "series__plane",
"series__examination" "series__examination",
# to_attr="prefetched_primary_answer" # to_attr="prefetched_primary_answer"
# queryset=self.Answer.objects.filter(), # queryset=self.Answer.objects.filter(),
# "series", # "series",
@@ -948,7 +978,7 @@ class ExamViews(View, LoginRequiredMixin):
queryset=self.Answer.objects.filter( queryset=self.Answer.objects.filter(
proposed=False, status=self.Answer.MarkOptions.CORRECT proposed=False, status=self.Answer.MarkOptions.CORRECT
), ),
to_attr="prefetched_primary_answer" to_attr="prefetched_primary_answer",
# queryset=self.Answer.objects.filter(), # queryset=self.Answer.objects.filter(),
), ),
"images", "images",
@@ -966,12 +996,17 @@ class ExamViews(View, LoginRequiredMixin):
# ) # )
elif self.app_name == "physics": elif self.app_name == "physics":
questions = ( questions = (
exam.exam_questions.select_related("category").all() exam.exam_questions.select_related("category")
.all()
.order_by("examquestiondetail__sort_order") .order_by("examquestiondetail__sort_order")
# .prefetch_related("images", "abnormality", "region", "examination") # .prefetch_related("images", "abnormality", "region", "examination")
) )
else: else:
questions = exam.exam_questions.select_related().all().order_by("examquestiondetail__sort_order") questions = (
exam.exam_questions.select_related()
.all()
.order_by("examquestiondetail__sort_order")
)
question_number = len(questions) question_number = len(questions)
@@ -984,7 +1019,6 @@ class ExamViews(View, LoginRequiredMixin):
# exam.exam_questions.select_related().all().order_by("examquestiondetail__sort_order").values_list("examquestiondetail__sort_order") # exam.exam_questions.select_related().all().order_by("examquestiondetail__sort_order").values_list("examquestiondetail__sort_order")
# TODO decide if / how this should be flagged to a user (updating teh exam order will fix) # TODO decide if / how this should be flagged to a user (updating teh exam order will fix)
return render( return render(
request, request,
"{}/exam_overview.html".format(self.app_name), "{}/exam_overview.html".format(self.app_name),
@@ -1049,7 +1083,9 @@ class ExamViews(View, LoginRequiredMixin):
if not request.user.is_superuser: if not request.user.is_superuser:
raise PermissionDenied raise PermissionDenied
cid_users = exam.valid_cid_users.all()#.order_by("cid")#.prefetch_related("*") cid_users = (
exam.valid_cid_users.all()
) # .order_by("cid")#.prefetch_related("*")
cid_user_count = len(cid_users) cid_user_count = len(cid_users)
user_users = exam.valid_user_users.all() # .order_by("username") user_users = exam.valid_user_users.all() # .order_by("username")
@@ -1080,7 +1116,6 @@ class ExamViews(View, LoginRequiredMixin):
# if request.user not in exam.author.all(): # if request.user not in exam.author.all():
# raise PermissionDenied # raise PermissionDenied
# #
# context = { # context = {
@@ -1208,7 +1243,9 @@ class ExamViews(View, LoginRequiredMixin):
if cid is not None: if cid is not None:
statuses = exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid) statuses = exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
elif user_id is not None: elif user_id is not None:
statuses = exam.exam_user_status.filter(cid_user_exam__user_user__id=user_id) statuses = exam.exam_user_status.filter(
cid_user_exam__user_user__id=user_id
)
else: else:
statuses = exam.exam_user_status.all() statuses = exam.exam_user_status.all()
@@ -1332,7 +1369,8 @@ class ExamViews(View, LoginRequiredMixin):
) )
case "longs": case "longs":
question_detail = LongsExamQuestionDetail.objects.get( question_detail = LongsExamQuestionDetail.objects.get(
question=question, exam=exam) question=question, exam=exam
)
case "physics": case "physics":
question_detail = PhysicsExamQuestionDetail.objects.get( question_detail = PhysicsExamQuestionDetail.objects.get(
question=question, exam=exam question=question, exam=exam
@@ -1408,7 +1446,7 @@ class ExamViews(View, LoginRequiredMixin):
queryset=self.Answer.objects.filter( queryset=self.Answer.objects.filter(
proposed=False, status=self.Answer.MarkOptions.CORRECT proposed=False, status=self.Answer.MarkOptions.CORRECT
), ),
to_attr="prefetched_primary_answer" to_attr="prefetched_primary_answer",
# queryset=self.Answer.objects.filter(), # queryset=self.Answer.objects.filter(),
), ),
# Prefetch( # Prefetch(
@@ -1490,7 +1528,9 @@ class ExamViews(View, LoginRequiredMixin):
) )
@method_decorator(login_required) @method_decorator(login_required)
def exam_question_user_answer(self, request, pk: int, sk: int, c_or_u: str, user_or_cid: str): def exam_question_user_answer(
self, request, pk: int, sk: int, c_or_u: str, user_or_cid: str
):
exam: ExamBase = get_object_or_404(self.Exam, pk=pk) exam: ExamBase = get_object_or_404(self.Exam, pk=pk)
if c_or_u == "u": if c_or_u == "u":
@@ -1801,7 +1841,9 @@ class ExamViews(View, LoginRequiredMixin):
cid_user=c, start_time=t cid_user=c, start_time=t
) )
exam.exam_user_status.create( exam.exam_user_status.create(
cid_user_exam=cid_user_exam, status="submitted", extra="manual submission" cid_user_exam=cid_user_exam,
status="submitted",
extra="manual submission",
) )
cid_user_exam.end_time = timezone.now() cid_user_exam.end_time = timezone.now()
@@ -1831,7 +1873,9 @@ class ExamViews(View, LoginRequiredMixin):
user_id = None user_id = None
else: else:
user_id = request.user.pk user_id = request.user.pk
if exam.exam_mode and not exam.check_cid_user(cid, passcode, request.user, user_id): if exam.exam_mode and not exam.check_cid_user(
cid, passcode, request.user, user_id
):
raise Http404("No available exam") raise Http404("No available exam")
# exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk)) # exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk))
@@ -2060,7 +2104,6 @@ class ExamViews(View, LoginRequiredMixin):
if self.app_name == "longs": if self.app_name == "longs":
feedback = user_answer.candidate_feedback feedback = user_answer.candidate_feedback
match self.app_name: match self.app_name:
case "sbas": case "sbas":
correct_answer = q.get_correct_answer() correct_answer = q.get_correct_answer()
@@ -2084,7 +2127,9 @@ class ExamViews(View, LoginRequiredMixin):
answers_marks.append(answer_score) answers_marks.append(answer_score)
print(q.get_questions(), ans, answer_score, correct_answer) print(q.get_questions(), ans, answer_score, correct_answer)
merged_ans = zip(q.get_questions(), ans, answer_score, correct_answer) merged_ans = zip(
q.get_questions(), ans, answer_score, correct_answer
)
answers_and_marks.append((q, merged_ans)) answers_and_marks.append((q, merged_ans))
case _: case _:
correct_answer = q.get_primary_answer() correct_answer = q.get_primary_answer()
@@ -2096,7 +2141,9 @@ class ExamViews(View, LoginRequiredMixin):
answers_marks.append(answer_score) answers_marks.append(answer_score)
if self.app_name == "longs": if self.app_name == "longs":
answers_and_marks.append((ans, answer_score, correct_answer, feedback)) answers_and_marks.append(
(ans, answer_score, correct_answer, feedback)
)
else: else:
answers_and_marks.append((ans, answer_score, correct_answer)) answers_and_marks.append((ans, answer_score, correct_answer))
@@ -2110,7 +2157,9 @@ class ExamViews(View, LoginRequiredMixin):
answered = [i for i in answers_marks if type(i) == int] answered = [i for i in answers_marks if type(i) == int]
total_score = sum(answered) total_score = sum(answered)
unmarked_number = len(answers_marks) - len(answered) unmarked_number = len(answers_marks) - len(answered)
total_score = "{} ({} unmarked)".format(total_score, unmarked_number) total_score = "{} ({} unmarked)".format(
total_score, unmarked_number
)
else: else:
total_score = sum(answers_marks) total_score = sum(answers_marks)
@@ -2259,9 +2308,6 @@ class ExamViews(View, LoginRequiredMixin):
zipped_ans_scores = zip(ans, answer_score) zipped_ans_scores = zip(ans, answer_score)
by_question[q][cid] = zipped_ans_scores by_question[q][cid] = zipped_ans_scores
user_scores = {} user_scores = {}
user_scores_normalised = {} user_scores_normalised = {}
user_answer_count = {} user_answer_count = {}
@@ -2278,7 +2324,6 @@ class ExamViews(View, LoginRequiredMixin):
by_question[question][user] = ("Not answered", 3.0) by_question[question][user] = ("Not answered", 3.0)
user_answers_marks[user].append(3.0) user_answers_marks[user].append(3.0)
if self.app_name in ("rapids", "anatomy", "sbas", "longs"): if self.app_name in ("rapids", "anatomy", "sbas", "longs"):
user_scores[user] = sum( user_scores[user] = sum(
[i for i in user_answers_marks[user] if i != "unmarked"] [i for i in user_answers_marks[user] if i != "unmarked"]
@@ -2400,9 +2445,9 @@ class ExamViews(View, LoginRequiredMixin):
} }
if self.app_name == "rapids": if self.app_name == "rapids":
template_variables[ template_variables["user_answers_callstates"] = (
"user_answers_callstates" user_answers_callstates_counted
] = user_answers_callstates_counted )
return render( return render(
request, request,
@@ -2419,9 +2464,7 @@ class ExamViews(View, LoginRequiredMixin):
if not exam.exam_mode: if not exam.exam_mode:
raise Http404("Packet not in exam mode") raise Http404("Packet not in exam mode")
template_variables = { template_variables = {"exam": exam}
"exam": exam
}
return render( return render(
request, request,
@@ -2476,7 +2519,7 @@ class GenericViewBase:
@method_decorator(login_required) @method_decorator(login_required)
def question_detail(self, request, pk): def question_detail(self, request, pk):
question = get_object_or_404(self.question_object, pk=pk) question: QuestionBase = get_object_or_404(self.question_object, pk=pk)
if not question.open_access: if not question.open_access:
if ( if (
@@ -2499,7 +2542,12 @@ class GenericViewBase:
return render( return render(
request, request,
f"{self.app_name}/question_detail.html", f"{self.app_name}/question_detail.html",
{"question": question, "view_feedback": view_feedback, "remote_url": settings.REMOTE_URL}, {
"question": question,
"view_feedback": view_feedback,
"remote_url": settings.REMOTE_URL,
"can_edit": question.can_edit(request.user),
},
) )
# TODO: improve permissions on these # TODO: improve permissions on these
@@ -2514,15 +2562,22 @@ class GenericViewBase:
question = get_object_or_404(self.question_object, pk=pk) question = get_object_or_404(self.question_object, pk=pk)
if answer_compare is None: if answer_compare is None:
answers = self.cid_user_answer_object.objects.filter(question__id=question.pk).prefetch_related("question", "exam", "user") answers = self.cid_user_answer_object.objects.filter(
question__id=question.pk
).prefetch_related("question", "exam", "user")
else: else:
answers = self.cid_user_answer_object.objects.filter(question__id=question.pk, answer_compare=answer_compare).prefetch_related("question", "exam", "user") answers = self.cid_user_answer_object.objects.filter(
question__id=question.pk, answer_compare=answer_compare
).prefetch_related("question", "exam", "user")
return render( return render(
request, request,
f"{self.app_name}/question_user_answers.html", f"{self.app_name}/question_user_answers.html",
{"question": question, "answers": answers, "answer_compare": answer_compare}, {
"question": question,
"answers": answers,
"answer_compare": answer_compare,
},
) )
@method_decorator(login_required) @method_decorator(login_required)
@@ -2548,12 +2603,11 @@ class GenericViewBase:
) )
class ExamCloneMixin(): class ExamCloneMixin:
def get_template_names(self) -> list[str]: def get_template_names(self) -> list[str]:
return "exam_clone_form.html" return "exam_clone_form.html"
# return super().get_template_names() # return super().get_template_names()
def get_initial(self): def get_initial(self):
old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"]) old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"])
@@ -2617,6 +2671,8 @@ class ExaminationAutocomplete(autocomplete.Select2QuerySetView):
return Examination.objects.none() return Examination.objects.none()
return qs return qs
class SupervisorAutocomplete(autocomplete.Select2QuerySetView): class SupervisorAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self): def get_queryset(self):
# TODO: we should probably filter this to only # TODO: we should probably filter this to only
@@ -2746,6 +2802,7 @@ def cid_group_view_all(request):
{"groups": groups, "view_all": True}, {"groups": groups, "view_all": True},
) )
@user_is_cid_user_manager @user_is_cid_user_manager
def user_group_add_candidates_to_exams(request, group_id): def user_group_add_candidates_to_exams(request, group_id):
if request.htmx: if request.htmx:
@@ -2766,11 +2823,10 @@ def user_group_add_candidates_to_exams(request, group_id):
return HttpResponse(f"Candidates added") return HttpResponse(f"Candidates added")
# exam.save() # exam.save()
raise PermissionDenied() # or Http404 raise PermissionDenied() # or Http404
pass pass
@user_is_cid_user_manager @user_is_cid_user_manager
def user_group_view_detail(request, group_id): def user_group_view_detail(request, group_id):
group = get_object_or_404(UserUserGroup, pk=group_id) group = get_object_or_404(UserUserGroup, pk=group_id)
@@ -2817,6 +2873,7 @@ def get_user_selection_from_request(request):
user_models = User.objects.filter(id__in=selected_users) user_models = User.objects.filter(id__in=selected_users)
return user_models return user_models
@user_is_cid_user_manager @user_is_cid_user_manager
def user_not_trainee(request, user_id): def user_not_trainee(request, user_id):
user = get_object_or_404(User, pk=user_id) user = get_object_or_404(User, pk=user_id)
@@ -2824,7 +2881,10 @@ def user_not_trainee(request, user_id):
user.userprofile.peninsula_trainee = False user.userprofile.peninsula_trainee = False
user.userprofile.save() user.userprofile.save()
return HttpResponse(f"{user.username} is no longer a trainee", content_type="text/plain") return HttpResponse(
f"{user.username} is no longer a trainee", content_type="text/plain"
)
@user_is_cid_user_manager @user_is_cid_user_manager
def users_bulk_edit(request): def users_bulk_edit(request):
@@ -2863,8 +2923,7 @@ def users_bulk_edit(request):
# TODO: work out how to actually use hyperscript # TODO: work out how to actually use hyperscript
html = ( html = (
html html
+ + f"""<button class='change-grade-button' id='add-grade--remove'
f"""<button class='change-grade-button' id='add-grade--remove'
hx-post="{reverse('generic:users_bulk_edit')}" hx-post="{reverse('generic:users_bulk_edit')}"
hx-include="[name='selection']" hx-include="[name='selection']"
hx-confirm="This will remove grades of all selected users, are you sure you wish to continue?" hx-confirm="This will remove grades of all selected users, are you sure you wish to continue?"
@@ -3065,6 +3124,7 @@ def candidate_email_results_resend(request, cid, resend=True):
def create_cid_email(request): def create_cid_email(request):
pass pass
@user_is_cid_user_manager @user_is_cid_user_manager
def cid_details(request, cid: int): def cid_details(request, cid: int):
print(cid) print(cid)
@@ -3077,6 +3137,7 @@ def cid_details(request, cid: int):
raise PermissionDenied() # or Http404 raise PermissionDenied() # or Http404
@user_is_cid_user_manager @user_is_cid_user_manager
def manage_cid_users(request): def manage_cid_users(request):
if request.method == "POST": if request.method == "POST":
@@ -3308,11 +3369,13 @@ class UserUserGroupUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView):
context["group_type"] = "user" context["group_type"] = "user"
return context return context
class UserUserGroupDelete(RevisionMixin, CidManagerRequiredMixin, DeleteView): class UserUserGroupDelete(RevisionMixin, CidManagerRequiredMixin, DeleteView):
model = UserUserGroup model = UserUserGroup
template_name = "confirm_delete.html" template_name = "confirm_delete.html"
success_url = reverse_lazy("generic:user_group_view") success_url = reverse_lazy("generic:user_group_view")
class UserGroupExamUpdate(CidManagerRequiredMixin, UpdateView): class UserGroupExamUpdate(CidManagerRequiredMixin, UpdateView):
model = UserUserGroup model = UserUserGroup
form_class = UserGroupExamForm form_class = UserGroupExamForm
@@ -3322,6 +3385,7 @@ class UserGroupExamUpdate(CidManagerRequiredMixin, UpdateView):
context["group_type"] = "user" context["group_type"] = "user"
return context return context
class CidGroupExamUpdate(CidManagerRequiredMixin, UpdateView): class CidGroupExamUpdate(CidManagerRequiredMixin, UpdateView):
model = CidUserGroup model = CidUserGroup
form_class = CidGroupExamForm form_class = CidGroupExamForm
@@ -3331,6 +3395,7 @@ class CidGroupExamUpdate(CidManagerRequiredMixin, UpdateView):
context["group_type"] = "cid" context["group_type"] = "cid"
return context return context
class ExamCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): class ExamCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
template_name = "exam_create_form.html" template_name = "exam_create_form.html"
@@ -3391,24 +3456,28 @@ def exam_inactive(request, context):
# #
# success_url = reverse_lazy("accounts_list") # success_url = reverse_lazy("accounts_list")
@user_is_cid_user_manager @user_is_cid_user_manager
def trainees(request, grade: None | str = None): def trainees(request, grade: None | str = None):
trainees = UserProfile.objects.filter(peninsula_trainee=True, user__is_active=True).order_by("grade__name", Lower("user__last_name")).prefetch_related("supervisor", "grade", "user") trainees = (
UserProfile.objects.filter(peninsula_trainee=True, user__is_active=True)
.order_by("grade__name", Lower("user__last_name"))
.prefetch_related("supervisor", "grade", "user")
)
if grade is not None: if grade is not None:
trainees = trainees.filter(grade__name=grade) trainees = trainees.filter(grade__name=grade)
context = { context = {"trainees": trainees, "grade": grade}
"trainees" : trainees,
"grade": grade
}
return render(request, "generic/trainees.html", context) return render(request, "generic/trainees.html", context)
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)
@user_is_cid_user_manager @user_is_cid_user_manager
def create_user(request, context=None, trainee: bool = False): def create_user(request, context=None, trainee: bool = False):
if trainee: if trainee:
@@ -3503,6 +3572,7 @@ class SupervisorCreate(CidManagerRequiredMixin, CreateView):
# class SupervisorList(CidManagerRequiredMixin, ListView): # class SupervisorList(CidManagerRequiredMixin, ListView):
# model = Supervisor # model = Supervisor
class SupervisorList(CidManagerRequiredMixin, SingleTableMixin, FilterView): class SupervisorList(CidManagerRequiredMixin, SingleTableMixin, FilterView):
model = Supervisor model = Supervisor
table_class = SupervisorTable table_class = SupervisorTable
@@ -3510,20 +3580,25 @@ class SupervisorList(CidManagerRequiredMixin, SingleTableMixin, FilterView):
filterset_class = SupervisorFilter filterset_class = SupervisorFilter
class ExamCollectionList(ListView): class ExamCollectionList(ListView):
model = ExamCollection model = ExamCollection
class ExamCollectionDetail(DetailView, AuthorRequiredMixin): class ExamCollectionDetail(DetailView, AuthorRequiredMixin):
model = ExamCollection model = ExamCollection
class ExamCollectionEdit(UpdateView, AuthorRequiredMixin): class ExamCollectionEdit(UpdateView, AuthorRequiredMixin):
model = ExamCollection model = ExamCollection
form_class = ExamCollectionForm form_class = ExamCollectionForm
class ExamCollectionCreate(CreateView, AuthorRequiredMixin): class ExamCollectionCreate(CreateView, AuthorRequiredMixin):
model = ExamCollection model = ExamCollection
form_class = ExamCollectionForm form_class = ExamCollectionForm
class ExamCollectionClone(CreateView, AuthorRequiredMixin): class ExamCollectionClone(CreateView, AuthorRequiredMixin):
model = ExamCollection model = ExamCollection
template_name = "generic/examcollection_clone_form.html" template_name = "generic/examcollection_clone_form.html"
@@ -3533,7 +3608,6 @@ class ExamCollectionClone(CreateView, AuthorRequiredMixin):
old_object = get_object_or_404(self.model, pk=self.kwargs["pk"]) old_object = get_object_or_404(self.model, pk=self.kwargs["pk"])
self.initial_object = old_object self.initial_object = old_object
initial_data = model_to_dict(old_object, exclude=["id", "date"]) initial_data = model_to_dict(old_object, exclude=["id", "date"])
## We manually transfer the forign keys / m2m relationships ## We manually transfer the forign keys / m2m relationships
@@ -3571,11 +3645,13 @@ class ExamCollectionClone(CreateView, AuthorRequiredMixin):
object.save() object.save()
return HttpResponseRedirect(object.get_absolute_url()) return HttpResponseRedirect(object.get_absolute_url())
class ExamCollectionDelete(DeleteView, AuthorRequiredMixin): class ExamCollectionDelete(DeleteView, AuthorRequiredMixin):
model = ExamCollection model = ExamCollection
template_name = "confirm_delete.html" template_name = "confirm_delete.html"
success_url = reverse_lazy("generic:examcollection_list") success_url = reverse_lazy("generic:examcollection_list")
class ExaminationView(SuperuserRequiredMixin, SingleTableMixin, FilterView): class ExaminationView(SuperuserRequiredMixin, SingleTableMixin, FilterView):
model = Examination model = Examination
table_class = ExaminationTable table_class = ExaminationTable
@@ -3597,7 +3673,8 @@ class ExaminationUpdate(UpdateView, SuperuserRequiredMixin):
class SeriesImagesZipViewBase(SuperuserRequiredMixin, View): class SeriesImagesZipViewBase(SuperuserRequiredMixin, View):
"""Download all images from an image series""" """Download all images from an image series"""
http_method_names = ['get']
http_method_names = ["get"]
series_object = None series_object = None
def get_files(self): def get_files(self):
@@ -3611,7 +3688,9 @@ class SeriesImagesZipViewBase(SuperuserRequiredMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
temp_file = ContentFile(b"", name=self.get_archive_name(request)) temp_file = ContentFile(b"", name=self.get_archive_name(request))
with zipfile.ZipFile(temp_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zip_file: with zipfile.ZipFile(
temp_file, mode="w", compression=zipfile.ZIP_DEFLATED
) as zip_file:
files = self.get_files() files = self.get_files()
for file_ in files: for file_ in files:
path = os.path.split(file_.name)[-1] path = os.path.split(file_.name)[-1]
@@ -3620,24 +3699,35 @@ class SeriesImagesZipViewBase(SuperuserRequiredMixin, View):
file_size = temp_file.tell() file_size = temp_file.tell()
temp_file.seek(0) temp_file.seek(0)
response = HttpResponse(temp_file, content_type='application/zip') response = HttpResponse(temp_file, content_type="application/zip")
response['Content-Disposition'] = 'attachment; filename=%s' % self.get_archive_name(request) response["Content-Disposition"] = (
response['Content-Length'] = file_size "attachment; filename=%s" % self.get_archive_name(request)
)
response["Content-Length"] = file_size
return response return response
class ExamGroupsUpdateBase( class ExamGroupsUpdateBase(
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView RevisionMixin,
CheckCanEditMixin,
LoginRequiredMixin,
AuthorRequiredMixin,
UpdateView,
): ):
template_name = "generic/exam_groups_edit.html" template_name = "generic/exam_groups_edit.html"
def get_success_url(self) -> str: def get_success_url(self) -> str:
return reverse_lazy(f"{self.object.get_app_name()}:exam_cids", kwargs={"exam_id": self.object.pk}) return reverse_lazy(
f"{self.object.get_app_name()}:exam_cids",
kwargs={"exam_id": self.object.pk},
)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super(ExamGroupsUpdateBase, self).get_form_kwargs() kwargs = super(ExamGroupsUpdateBase, self).get_form_kwargs()
kwargs.update({"user": self.request.user}) kwargs.update({"user": self.request.user})
return kwargs return kwargs
class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView): class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView):
def get_form_kwargs(self): def get_form_kwargs(self):
@@ -3661,4 +3751,3 @@ class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView):
if self.request.user in obj.get_author_objects(): if self.request.user in obj.get_author_objects():
return context return context
raise PermissionDenied() # or Http404 raise PermissionDenied() # or Http404