Compare commits

...
17 Commits
Author SHA1 Message Date
Ross 3f5f39c41c further improve anatomy questions 2024-10-07 14:00:45 +01:00
Ross 92663bc620 . 2024-10-07 11:46:36 +01:00
Ross 359a399cc8 . 2024-10-07 11:44:12 +01:00
Ross 883b11a689 . 2024-10-07 11:26:02 +01:00
Ross 2e22846d23 . 2024-10-07 11:19:22 +01:00
Ross ef8d8f3ef1 . 2024-10-07 11:07:00 +01:00
Ross 51029d1b83 . 2024-10-07 11:06:44 +01:00
Ross 1d04f9ae9f . 2024-10-07 11:05:11 +01:00
Ross 48e6339ecb strat tidying up anatomy question form 2024-10-07 11:04:07 +01:00
Ross 3616d1b9c5 . 2024-10-07 09:54:08 +01:00
Ross 41dd5b7086 . 2024-10-07 09:52:59 +01:00
Ross 707a98629d . 2024-10-07 09:38:29 +01:00
Ross 8833fb6105 . 2024-10-07 09:36:51 +01:00
Ross e6d8317fc5 . 2024-10-07 09:30:41 +01:00
Ross d1249f14d8 migrate to use image id in model 2024-10-07 09:28:43 +01:00
Ross 6f96f79303 fix some more loading errors 2024-10-07 09:20:09 +01:00
Ross ea42f7f0c7 update dicom viewer to support load by imageid 2024-10-07 08:58:15 +01:00
16 changed files with 565 additions and 204 deletions
+15 -4
View File
@@ -33,6 +33,9 @@ from dal import autocomplete
from tinymce.widgets import TinyMCE
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
class AnatomyAnswerForm(ModelForm):
class Meta:
model = UserAnswer
@@ -76,6 +79,14 @@ class AnatomyQuestionForm(ModelForm):
ModelForm.__init__(self, *args, **kwargs)
super(AnatomyQuestionForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_id = 'id-anatomy-question-form'
#self.helper.form_class = 'blueForms'
#self.helper.form_method = 'post'
#self.helper.form_action = ''
#self.helper.add_input(Submit('submit', 'Submit'))
# self.fields['question'].widget.attrs = {'class': 'question-form', 'rows': 10, 'cols': 100}
# self.fields['feedback'].widget.attrs = {'class': 'feedback-form', 'rows': 10, 'cols': 100}
self.fields["question_type"] = ModelChoiceField(
@@ -145,15 +156,15 @@ class AnatomyQuestionForm(ModelForm):
fields = [
"question_type",
"image",
"answer_help",
"answer_suggest_incorrect",
"region",
"description",
"modality",
"region",
"structure",
"feedback",
"examination",
"body_part",
"description",
"answer_help",
"answer_suggest_incorrect",
"open_access",
]
@@ -0,0 +1,36 @@
# Generated by Django 5.0.2 on 2024-10-07 10:05
import anatomy.models
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('anatomy', '0020_alter_exam_markers'),
]
operations = [
migrations.AlterField(
model_name='anatomyquestion',
name='answer_help',
field=models.TextField(blank=True, default='', help_text='Helpful information for marking, this will not be displayed to the user taking the exam.', null=True),
),
migrations.AlterField(
model_name='anatomyquestion',
name='answer_suggest_incorrect',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, null=True), blank=True, default=list, help_text='An array that defines text that if found in the answer is likely incorrect. This is used to aid marking', size=None),
),
migrations.AlterField(
model_name='anatomyquestion',
name='image',
field=models.ImageField(help_text="The image to use for the question. Ideally use unmarked images and annotate (arrow) them on the test system. If you wish to reuse an image that is already uploaded 'clone' the question that contains it.", upload_to=anatomy.models.image_directory_path),
),
migrations.AlterField(
model_name='anatomyquestion',
name='question_type',
field=models.ForeignKey(default=1, help_text='Type of question, this is the question text that will be displayed to the user.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='anatomy.questiontype'),
),
]
+10 -4
View File
@@ -72,12 +72,12 @@ class QuestionType(models.Model):
@reversion.register
class AnatomyQuestion(QuestionBase):
question_type = models.ForeignKey(
QuestionType, on_delete=models.SET_NULL, null=True, default=1
QuestionType, on_delete=models.SET_NULL, null=True, default=1, help_text="Type of question, this is the question text that will be displayed to the user."
)
image = models.ImageField(
upload_to=image_directory_path,
help_text="The image to use for the question. Ideally use use unmarked images and annotate (arrow) them on the test system. If you wish to reuse an image that is already uploaded 'clone' the question that contains it.",
help_text="The image to use for the question. Ideally use unmarked images and annotate (arrow) them on the test system. If you wish to reuse an image that is already uploaded 'clone' the question that contains it.",
)
image_annotations = models.TextField(
@@ -90,9 +90,9 @@ class AnatomyQuestion(QuestionBase):
help_text="Short description of the image e.g. 'Sagittal CT Chest, Abdomen & Pelvis', will be displayed as the title.",
)
answer_help = models.TextField(default="", blank=True, null=True, help_text="Helpful information for marking")
answer_help = models.TextField(default="", blank=True, null=True, help_text="Helpful information for marking, this will not be displayed to the user taking the exam.")
answer_suggest_incorrect = ArrayField(models.CharField(max_length=255, null=True), default=list, blank=True)
answer_suggest_incorrect = ArrayField(models.CharField(max_length=255, null=True), default=list, blank=True, help_text="An array that defines text that if found in the answer is likely incorrect. This is used to aid marking")
examination = models.ForeignKey(
Examination, on_delete=models.SET_NULL, null=True, blank=True
@@ -329,6 +329,12 @@ class Answer(models.Model):
self.answer = self.answer.strip()
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):
# s = self.answer.lower().strip()
# s = s.translate(str.maketrans('', '', string.punctuation))
+51 -32
View File
@@ -585,13 +585,14 @@ export function loadCornerstone(main_element, db, images, annotations_to_load, l
//});
} else {
let url;
// This doesn't seem to have any benefit
//if (data_url.startsWith("http")) {
// url = data_url;
//} else {
// url = window.location.href.replace(/\/\#\/?$/, '') + "/" + data_url
//}
url = data_url;
// Cornerstone needs the whole url to load? (not relative)
if (data_url.startsWith("http")) {
url = data_url;
} else {
url = window.location.href.replace(/\/\#\/?$/, '').replace("index.html", "") + "/" + data_url
}
//url = data_url;
if (url.endsWith("dcm")) {
url = "wadouri:" + url;
@@ -618,7 +619,11 @@ export function loadCornerstone(main_element, db, images, annotations_to_load, l
//console.log("LOAD and cache, then", image);
loadCornerstoneMainImage(element, image, stack, db, load_as_stack);
checkAnonStatus(image.data)
// We can only check dicom data if it exists (e.g. not on jpegs)
if (image.data != undefined) {
checkAnonStatus(image.data)
}
let load_annotation_image = true;
if (load_annotation_image) {
@@ -1079,6 +1084,10 @@ function onNewImage(e) {
}
function checkAnonStatus(dataSet) {
// dataSet will be undefined if the image is not a dicom
if (dataSet == undefined) {
return;
}
let accession_number = dataSet.string('x00080050').toLowerCase();
let patient_id = dataSet.string('x00100020').toLowerCase();
@@ -1215,6 +1224,16 @@ export function loadStackIndex(new_index, dicom_element) {
//c = cornerstone.getEnabledElement(dicom_element)
}
export function loadImageById(imageId, dicom_element) {
// Update stack index
let c = cornerstone.getEnabledElement(dicom_element);
let new_index = c.toolStateManager.toolState.stack.data[0].imageIds.indexOf(imageId);
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex = new_index;
cornerstone.loadImage(imageId).then(b => {
cornerstone.displayImage(dicom_element, b);
});
}
function urltoFile(url, filename, mimeType) {
return fetch(url)
.then(function (res) {
@@ -1277,34 +1296,34 @@ export function getNextAnnotationImage(element) {
}
function manualScrollDicom(n, dicom_element) {
// There must be a better way to do this...
//dicom_element = document.getElementById("dicom-image");
let c = cornerstone.getEnabledElement(dicom_element);
// There must be a better way to do this...
//dicom_element = document.getElementById("dicom-image");
let c = cornerstone.getEnabledElement(dicom_element);
let max = c.toolStateManager.toolState.stack.data[0].imageIds.length;
let max = c.toolStateManager.toolState.stack.data[0].imageIds.length;
if (max < 2) {
return;
}
if (max < 2) {
return;
}
let current_index =
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex;
let current_index =
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex;
let new_index = current_index + n
let corrected_index = Math.min(Math.max(new_index, 0), max)
let new_index = current_index + n
// TODO: if loop
//if (new_index >= max) {
// corrected_index = new_index - max;
//} else if (new_index < 0) {
// corrected_index = new_index + max;
//}
let corrected_index = Math.min(Math.max(new_index, 0), max)
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex = corrected_index;
let id = c.toolStateManager.toolState.stack.data[0].imageIds[corrected_index];
cornerstone.loadImage(id).then((b) => {
cornerstone.displayImage(dicom_element, b);
});
// c = cornerstone.getEnabledElement(dicom_element)
// TODO: if loop
//if (new_index >= max) {
// corrected_index = new_index - max;
//} else if (new_index < 0) {
// corrected_index = new_index + max;
//}
c.toolStateManager.toolState.stack.data[0].currentImageIdIndex = corrected_index;
let id = c.toolStateManager.toolState.stack.data[0].imageIds[corrected_index];
cornerstone.loadImage(id).then((b) => {
cornerstone.displayImage(dicom_element, b);
});
// c = cornerstone.getEnabledElement(dicom_element)
}
@@ -1,6 +1,12 @@
{% extends "anatomy/base.html" %}
{% load static %}
{% load crispy_forms_tags %}
{% block title %}
Anatomy
{% endblock %}
{% block js %}
<!--<script type="text/javascript" src="/admin/jsi18n/"></script>-->
@@ -220,6 +226,12 @@
{% endif %}
<h2>{{object|yesno:"Update,Create"}} Question</h2>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This form can be used to create an Anatomy question.</p>
<p>Question type can be selected from a predefined list. If you require a more please contact ross.kruger@nhs.net.</p>
</details>
<form action="" method="post" enctype="multipart/form-data" id="anatomyquestion-form">
{% csrf_token %}
<a href="/anatomy/examination/create" id="add_examination" class="add-popup"
@@ -227,9 +239,7 @@
<a href="/anatomy/body_part/create" id="add_body_part" class="add-popup"
onclick="return showAddPopup(this);"><img src="{% static '/img/icon-addlink.svg' %}"></a>
<table>
{{ form.as_table }}
</table>
{{ form|crispy }}
<div id="drop-container" class="drop-target">Drop image here
<div id="drop-filenames"></div>
</div>
+65 -28
View File
@@ -2,38 +2,58 @@
{% block content %}
{% load static %}
<!-- testing -->
<!--<div id="dicom-image" data-url="http://localhost:8000/static/abdoct.jpg"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true></div>-->
{% include 'anatomy/question_link_header.html' %}
<button id="save-annotations">Save Annotations</button>
<div id="dicom-image" class="dicom-image" data-url="https://www.penracourses.org.uk{{ question.image.url}}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true></div>
<div id="dicom-image" class="dicom-image" data-url="{{ remote_url }}{{ question.image.url}}"
data-annotations='{{question.image_annotations}}' data-edit_annotation=true>
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>Annotate the image using the right mouse button to click and drag an arrow. To remove an arrow drag it out of the image boundaries. To save the changes click the "Save Annotations" button.</p>
<p>There is no limit to the number of arrows that can be added to the image.</p>
</details>
<button id="save-annotations">Save Annotations</button>
</div>
<div class="question">
<div class="date">
Created: {{ question.created_date|date:"d/m/Y" }}
</div>
<h1>{{ question.get_primary_answer }}</h1>
<h2>{{question.question_type}}</h2>
<details>
<summary>
Answers:
</summary>
<table>
<tr><th>Answer</th><th>Score</th></tr>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
<td>
<span {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
{% endif %}>
{{ answer }}
</span>
<td>
<td>{{answer.status}}</td>
</tr>
{% endfor %}
</table>
</details>
<h2>Question type: {{question.question_type}}</h2>
<h3>Primary answer: {{ question.get_primary_answer }}</h3>
<div>
<details>
<summary title="Click to view the question answers">
Answers:
</summary>
<table>
<tr><th>Answer</th><th>Score</th></tr>
{% for answer in question.answers.all|dictsortreversed:"status" %}
<tr>
<td {% if answer.proposed %}class="proposed-answer" data-aid="{{answer.pk}}" data-question-type="anatomy"
{% endif %}>
<span >
{{ answer }}
</span>
<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>
{% endfor %}
</table>
</details>
</div>
<div>
Answer help: {{ question.answer_help|safe }}
</div>
@@ -47,6 +67,10 @@
Exams: {% for exam in question.exams.all %}
<a href="{% url 'anatomy:exam_overview' pk=exam.pk %}">{{ exam }}</a>
{% 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>
Modality: {{ question.modality }}
@@ -143,4 +167,17 @@
}
</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(),
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/all", views.mark_all, name="mark_all"),
path(
+88 -1
View File
@@ -26,6 +26,11 @@ from django.http import HttpResponseRedirect, HttpResponse
from dal import autocomplete
from django.conf import settings
from django.utils.html import format_html_join
from django.utils.safestring import mark_safe
from .forms import (
AnatomyAnswerForm,
AnatomyQuestionAnswerForm,
@@ -494,6 +499,8 @@ class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs):
context = super(AnatomyQuestionCreateBase, self).get_context_data(**kwargs)
if "object" in context:
context["question"] = context["object"]
if self.request.POST:
context["answer_formset"] = AnswerFormSet(
self.request.POST, self.request.FILES
@@ -526,6 +533,9 @@ class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
else:
return super().form_invalid(form)
def get_success_url(self) -> str:
return redirect("anatomy:question_detail", pk=self.object.pk)
class AnatomyQuestionCreate(AnatomyQuestionCreateBase):
@@ -540,7 +550,6 @@ class AnatomyQuestionCreate(AnatomyQuestionCreateBase):
return initial
# # There has to be a better way...
# try:
# s = (i.pk for i in self.request.user.AnatomyQuestion_default.site.all())
@@ -844,3 +853,81 @@ GenericViews = GenericViewBase("anatomy", AnatomyQuestion, UserAnswer, Exam)
class ExamClone(ExamCloneMixin, ExamCreate):
"""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()
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-10-07 08:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0060_seriesfinding_current_image_id_index'),
]
operations = [
migrations.AlterField(
model_name='seriesfinding',
name='current_image_id_index',
field=models.TextField(blank=True, null=True),
),
]
+2 -1
View File
@@ -597,7 +597,8 @@ class SeriesFinding(models.Model):
conditions = models.ManyToManyField(Condition, blank=True)
annotation_json = models.TextField(null=True, blank=True)
viewport_json = models.TextField(null=True, blank=True)
current_image_id_index = models.IntegerField(null=True, blank=True)
# This has been migrated to install the image id (not index)
current_image_id_index = models.TextField(null=True, blank=True)
def __str__(self) -> str:
findings = self.findings.all().values_list("name")
+20 -7
View File
@@ -215,9 +215,10 @@
$("#add-finding-button").click(() => {
$("#hidden-form").show()
dicom_element = $(".cornerstone-element").get(0);
cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
cornerstone.reset(dicom_element);
cornerstone.resize(dicom_element, true);
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
//cornerstone.reset(dicom_element);
////cornerstone.resize(dicom_element, true);
$("#add-finding-button").hide()
//$("#finding-form").empty().append(
// $("#hidden-form form").clone()
//);
@@ -225,6 +226,7 @@
});
$("#cancel-add-finding-button").click((e) => {
$("#hidden-form").hide()
$("#add-finding-button").show()
e.preventDefault();
});
@@ -279,15 +281,23 @@
//cornerstone.getEnabledElement(dicom_element).toolStateManager.restoreToolState(annotationjson)
cornerstone.resize(dicom_element);
current_image_id_index = parseInt(current_image_id_index);
console.log("current_image_id_index", current_image_id_index)
//current_image_id_index = parseInt(current_image_id_index);
console.log(current_image_id_index, Number.isInteger(current_image_id_index))
if (Number.isInteger(current_image_id_index)) {
console.log("loading stack index")
dicomViewer.loadStackIndex(current_image_id_index, dicom_element);
} else {
console.log("loading next annotation image")
dicomViewer.getNextAnnotationImage(dicom_element);
try {
console.log("current_image_id_index", current_image_id_index)
dicomViewer.loadImageById(current_image_id_index, dicom_element);
} catch (e) {
console.log(e)
console.log("loading image by id failed")
console.log("loading next annotation image")
dicomViewer.getNextAnnotationImage(dicom_element);
}
}
}
@@ -305,7 +315,10 @@
description: $('#id_description').val(),
series: {{ series.pk }},
//annotation_json: JSON.stringify(c.toolStateManager.saveToolState()),
current_image_id_index: c.toolStateManager.toolState.stack.data[0].currentImageIdIndex,
// This is the stack number
//current_image_id_index: c.toolStateManager.toolState.stack.data[0].currentImageIdIndex,
// but we want to use the current image id (so if the stack is reordered it still attaches to the correct image)
current_image_id_index: c.toolStateManager.toolState.stack.data[0].imageIds[c.toolStateManager.toolState.stack.data[0].currentImageIdIndex],
annotation_json: JSON.stringify(cornerstoneTools.globalImageIdSpecificToolStateManager
.saveToolState()),
viewport_json: JSON.stringify(c.viewport),
+14 -1
View File
@@ -1,6 +1,9 @@
import json
import pprint
from typing import Any
from dal import autocomplete
from django.db.models.base import Model as Model
from django.db.models.query import QuerySet
from django.shortcuts import render, get_object_or_404, redirect
from django import forms
from django.utils import timezone
@@ -563,12 +566,21 @@ class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
template_name = "confirm_delete.html"
# TODO fix permissions
class SeriesFindingDelete(RevisionMixin, PermissionRequiredMixin, DeleteView):
permission_required = "atlas.delete_seriesfinding"
model = SeriesFinding
template_name = "confirm_delete.html"
# success_url = reverse_lazy("atlas:case_view")
def get_object(self):
series_finding = super().get_object()
series = series_finding.series
if series.check_user_can_edit(self.request.user):
return series_finding
else:
raise PermissionDenied
def get_success_url(self):
pk = self.kwargs["pk"]
series_id = get_object_or_404(SeriesFinding, pk=pk).series.id
@@ -1375,6 +1387,7 @@ class SeriesImagesZipView(SeriesImagesZipViewBase, SuperuserRequiredMixin):
series_object = Series
# At the moment only the series owner / author can delete findings but anyone can create
def create_series_findings(request):
# posts = Post.objects.all()
response_data = {}
@@ -1388,7 +1401,7 @@ def create_series_findings(request):
description = request.POST.get("description")
annotation_json = request.POST.get("annotation_json")
viewport_json = request.POST.get("viewport_json")
current_image_id_index = int(request.POST.get("current_image_id_index"))
current_image_id_index = request.POST.get("current_image_id_index")
series = Series.objects.get(pk=series_id)
findings = Finding.objects.filter(pk__in=findings_ids)
-9
View File
@@ -78,9 +78,6 @@ class FirstImageColumn(tables.Column):
class CidUserTable(tables.Table):
# edit = tables.LinkColumn(
# "anatomy:anatomy_question_update", text="Edit", args=[A("pk")], orderable=False
# )
cid = tables.LinkColumn(
"cid_scores", args=[A("cid"), A("passcode")], orderable=True
)
@@ -129,9 +126,6 @@ class CidUserTable(tables.Table):
class CidUserExamTable(tables.Table):
# edit = tables.LinkColumn(
# "anatomy:anatomy_question_update", text="Edit", args=[A("pk")], orderable=False
# )
cid = tables.LinkColumn(
"cid_scores", args=[A("cid"), A("passcode")], orderable=True
)
@@ -189,9 +183,6 @@ class CidUserExamTable(tables.Table):
class UserUserTable(tables.Table):
# edit = tables.LinkColumn(
# "anatomy:anatomy_question_update", text="Edit", args=[A("pk")], orderable=False
# )
user = tables.Column(
linkify={"viewname": "account_profile", "args": [A("username")]},
+200 -110
View File
@@ -48,7 +48,12 @@ from atlas.models import CaseCollection, CaseDetail
from generic.decorators import user_is_cid_user_manager
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
import zipfile
@@ -79,6 +84,7 @@ from .models import (
ExamCollection,
ExamUserStatus,
Examination,
QuestionBase,
QuestionNote,
Supervisor,
UserGrades,
@@ -87,15 +93,31 @@ from .models import (
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 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 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 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 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 django.db.models import Case, When
@@ -112,13 +134,13 @@ import plotly.express as px
from django.db.models import Prefetch
class RedirectMixin():
class RedirectMixin:
def get_success_url(self) -> str:
if "redirect" in self.request.GET:
return self.request.GET["redirect"]
return super().get_success_url()
class AuthorRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
@@ -141,6 +163,7 @@ class CidManagerRequiredMixin(UserPassesTestMixin):
# return obj
# raise PermissionDenied() # or Http404
def get_exam_model_from_app_name(app_name: str) -> ExamBase:
EXAM_MAP = {
"physics": PhysicsExam,
@@ -471,7 +494,7 @@ class ExamViews(View, LoginRequiredMixin):
if (exam.open_access and exam.active) or user in exam.get_author_objects():
return True
if marker and user in exam.markers.all():
if marker and user in exam.markers.all():
return True
if exam.authors_only:
@@ -535,7 +558,7 @@ class ExamViews(View, LoginRequiredMixin):
if exam_id is not None:
exam = get_object_or_404(self.Exam, pk=exam_id)
# Remove open_access check for the momement
#if exam.open_access or user in exam.get_author_objects():
# if exam.open_access or user in exam.get_author_objects():
# return True
if user in exam.get_author_objects():
return True
@@ -687,13 +710,21 @@ class ExamViews(View, LoginRequiredMixin):
def exam_list(self, request, all=False):
if not self.check_user_access(request.user):
# raise PermissionDenied
exam_list = self.Exam.objects.filter(author__id=request.user.id, exam_mode=True).order_by(
"name"
)
exam_list = self.Exam.objects.filter(
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:
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:
exam_list = exam_list.filter(archive=False).order_by("name")
@@ -743,7 +774,7 @@ class ExamViews(View, LoginRequiredMixin):
status = {}
for user in users:
#user = User.objects.get(pk=u)
# user = User.objects.get(pk=u)
user_exam = exam.get_or_create_cid_user_exam(user_user=user)
if user_exam.results_emailed_status:
status[user] = json.loads(user_exam.results_emailed_status)
@@ -796,7 +827,7 @@ class ExamViews(View, LoginRequiredMixin):
email_results = {}
for user in users:
#user = User.objects.get(pk=u)
# user = User.objects.get(pk=u)
user_exam = exam.get_or_create_cid_user_exam(user_user=user)
if unsent_only and user_exam.results_emailed_status:
@@ -904,7 +935,7 @@ class ExamViews(View, LoginRequiredMixin):
queryset=self.Answer.objects.filter(
proposed=False, status=self.Answer.MarkOptions.CORRECT
),
to_attr="prefetched_primary_answer"
to_attr="prefetched_primary_answer",
# queryset=self.Answer.objects.filter(),
),
)
@@ -913,21 +944,20 @@ class ExamViews(View, LoginRequiredMixin):
elif self.app_name == "longs":
questions = (
exam.exam_questions.prefetch_related(
#Prefetch(
# Prefetch(
# "series",
# queryset=LongSeries.objects.select_related(
# "modality", "examination", "plane", "contrast"
# ).prefetch_related(Prefetch("images")),
#),
# ),
"author",
#"examquestiondetail",
#"longsseries",
# "examquestiondetail",
# "longsseries",
"series",
#"seriesdetail",
# "seriesdetail",
"series__images",
"series__plane",
"series__examination"
"series__examination",
# to_attr="prefetched_primary_answer"
# queryset=self.Answer.objects.filter(),
# "series",
@@ -948,7 +978,7 @@ class ExamViews(View, LoginRequiredMixin):
queryset=self.Answer.objects.filter(
proposed=False, status=self.Answer.MarkOptions.CORRECT
),
to_attr="prefetched_primary_answer"
to_attr="prefetched_primary_answer",
# queryset=self.Answer.objects.filter(),
),
"images",
@@ -966,25 +996,29 @@ class ExamViews(View, LoginRequiredMixin):
# )
elif self.app_name == "physics":
questions = (
exam.exam_questions.select_related("category").all()
exam.exam_questions.select_related("category")
.all()
.order_by("examquestiondetail__sort_order")
# .prefetch_related("images", "abnormality", "region", "examination")
)
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)
cid_candidates = exam.valid_cid_users.count()
users_candidates = exam.valid_user_users.count()
#question_orders = [q.examquestiondetail.sort_order for q in questions]
# question_orders = [q.examquestiondetail.sort_order for q in questions]
# Exam order can be missing when, this can be checked with
# 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)
return render(
request,
"{}/exam_overview.html".format(self.app_name),
@@ -1049,10 +1083,12 @@ class ExamViews(View, LoginRequiredMixin):
if not request.user.is_superuser:
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)
user_users = exam.valid_user_users.all()#.order_by("username")
user_users = exam.valid_user_users.all() # .order_by("username")
user_user_count = len(user_users)
user_exam_data = exam.cid_users.all().prefetch_related("cid_user", "user_user")
@@ -1072,7 +1108,7 @@ class ExamViews(View, LoginRequiredMixin):
return render(request, "exam_cids.html", context)
#def exam_groups_edit(self, request, exam_id):
# def exam_groups_edit(self, request, exam_id):
# exam = get_object_or_404(self.Exam, pk=exam_id)
# if not request.user.groups.filter(name="cid_user_manager").exists():
@@ -1080,8 +1116,7 @@ class ExamViews(View, LoginRequiredMixin):
# if request.user not in exam.author.all():
# raise PermissionDenied
#
#
# context = {
# "exam": exam,
@@ -1208,7 +1243,9 @@ class ExamViews(View, LoginRequiredMixin):
if cid is not None:
statuses = exam.exam_user_status.filter(cid_user_exam__cid_user__cid=cid)
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:
statuses = exam.exam_user_status.all()
@@ -1332,7 +1369,8 @@ class ExamViews(View, LoginRequiredMixin):
)
case "longs":
question_detail = LongsExamQuestionDetail.objects.get(
question=question, exam=exam)
question=question, exam=exam
)
case "physics":
question_detail = PhysicsExamQuestionDetail.objects.get(
question=question, exam=exam
@@ -1341,7 +1379,7 @@ class ExamViews(View, LoginRequiredMixin):
question_detail = SbasExamQuestionDetail.objects.get(
question=question, exam=exam
)
case _:
case _:
data = {"status": "error"}
return JsonResponse(data, status=200)
question_detail.sort_order = n
@@ -1408,7 +1446,7 @@ class ExamViews(View, LoginRequiredMixin):
queryset=self.Answer.objects.filter(
proposed=False, status=self.Answer.MarkOptions.CORRECT
),
to_attr="prefetched_primary_answer"
to_attr="prefetched_primary_answer",
# queryset=self.Answer.objects.filter(),
),
# Prefetch(
@@ -1490,15 +1528,17 @@ class ExamViews(View, LoginRequiredMixin):
)
@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)
if c_or_u == "u":
user = get_object_or_404(User,pk=user_or_cid)
user = get_object_or_404(User, pk=user_or_cid)
answer = exam.get_question_user_user_answer(sk, user)
elif c_or_u == "c":
#cid_user = CidUser.objects.filter(cid=user_or_cid)
answer =exam.get_question_cid_user_answer(sk, user_or_cid)
# cid_user = CidUser.objects.filter(cid=user_or_cid)
answer = exam.get_question_cid_user_answer(sk, user_or_cid)
else:
raise Http404
@@ -1510,7 +1550,7 @@ class ExamViews(View, LoginRequiredMixin):
exam: ExamBase = get_object_or_404(self.Exam, pk=pk)
cid_user = CidUser.objects.filter(cid=cid)
answer =exam.get_question_cid_user_answer(sk, cid_user)
answer = exam.get_question_cid_user_answer(sk, cid_user)
print(answer)
@@ -1522,12 +1562,12 @@ class ExamViews(View, LoginRequiredMixin):
if not self.check_user_access(request.user, pk, marker=True):
raise PermissionDenied
#question = exam.get_questions()[sk]
# question = exam.get_questions()[sk]
question = exam.get_question_by_index(sk)
exam_length = len(exam.exam_questions.all())
#pos = exam.get_question_index(question) + 1
# pos = exam.get_question_index(question) + 1
pos = sk + 1
previous = -1
@@ -1559,6 +1599,7 @@ class ExamViews(View, LoginRequiredMixin):
"pos": pos,
"view_feedback": view_feedback,
"can_edit": can_edit,
"remote_url": settings.REMOTE_URL,
},
)
@@ -1800,7 +1841,9 @@ class ExamViews(View, LoginRequiredMixin):
cid_user=c, start_time=t
)
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()
@@ -1830,7 +1873,9 @@ class ExamViews(View, LoginRequiredMixin):
user_id = None
else:
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")
# exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk))
@@ -2009,7 +2054,7 @@ class ExamViews(View, LoginRequiredMixin):
if not exam.check_cid_user(cid, passcode, request.user):
if not self.check_user_access(request.user, pk):
#raise PermissionDenied
# raise PermissionDenied
raise Http404("Error accessing exam")
if user is not None:
@@ -2059,7 +2104,6 @@ class ExamViews(View, LoginRequiredMixin):
if self.app_name == "longs":
feedback = user_answer.candidate_feedback
match self.app_name:
case "sbas":
correct_answer = q.get_correct_answer()
@@ -2083,7 +2127,9 @@ class ExamViews(View, LoginRequiredMixin):
answers_marks.append(answer_score)
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))
case _:
correct_answer = q.get_primary_answer()
@@ -2095,7 +2141,9 @@ class ExamViews(View, LoginRequiredMixin):
answers_marks.append(answer_score)
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:
answers_and_marks.append((ans, answer_score, correct_answer))
@@ -2109,7 +2157,9 @@ class ExamViews(View, LoginRequiredMixin):
answered = [i for i in answers_marks if type(i) == int]
total_score = sum(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:
total_score = sum(answers_marks)
@@ -2171,7 +2221,7 @@ class ExamViews(View, LoginRequiredMixin):
# user_answers_and_marks = defaultdict(list)
user_answers_marks = defaultdict(list)
#user_answers = defaultdict(list)
# user_answers = defaultdict(list)
# user_names = {}
# cid_passcodes = {}
@@ -2245,7 +2295,7 @@ class ExamViews(View, LoginRequiredMixin):
if answer_score == "unmarked":
index = exam.get_question_index(q)
unmarked.add(index)
#user_answers[cid].append(ans)
# user_answers[cid].append(ans)
user_answers_marks[cid].append(answer_score)
if self.app_name == "rapids":
@@ -2258,9 +2308,6 @@ class ExamViews(View, LoginRequiredMixin):
zipped_ans_scores = zip(ans, answer_score)
by_question[q][cid] = zipped_ans_scores
user_scores = {}
user_scores_normalised = {}
user_answer_count = {}
@@ -2273,11 +2320,10 @@ class ExamViews(View, LoginRequiredMixin):
# If either question or user do not exist we give a default
# not answered == score of 3
if user not in by_question[question]:
#print("NOT in")
# print("NOT in")
by_question[question][user] = ("Not answered", 3.0)
user_answers_marks[user].append(3.0)
if self.app_name in ("rapids", "anatomy", "sbas", "longs"):
user_scores[user] = sum(
[i for i in user_answers_marks[user] if i != "unmarked"]
@@ -2312,7 +2358,7 @@ class ExamViews(View, LoginRequiredMixin):
mode = 0
fig_html = ""
else:
#Exclude very low scores from statistics
# Exclude very low scores from statistics
lower_score_bound = max_score / 5
bounded_scores = [i for i in user_scores_list if i > lower_score_bound]
if bounded_scores:
@@ -2399,9 +2445,9 @@ class ExamViews(View, LoginRequiredMixin):
}
if self.app_name == "rapids":
template_variables[
"user_answers_callstates"
] = user_answers_callstates_counted
template_variables["user_answers_callstates"] = (
user_answers_callstates_counted
)
return render(
request,
@@ -2418,9 +2464,7 @@ class ExamViews(View, LoginRequiredMixin):
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
template_variables = {
"exam": exam
}
template_variables = {"exam": exam}
return render(
request,
@@ -2475,7 +2519,7 @@ class GenericViewBase:
@method_decorator(login_required)
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 (
@@ -2498,7 +2542,12 @@ class GenericViewBase:
return render(
request,
f"{self.app_name}/question_detail.html",
{"question": question, "view_feedback": view_feedback},
{
"question": question,
"view_feedback": view_feedback,
"remote_url": settings.REMOTE_URL,
"can_edit": question.can_edit(request.user),
},
)
# TODO: improve permissions on these
@@ -2513,15 +2562,22 @@ class GenericViewBase:
question = get_object_or_404(self.question_object, pk=pk)
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:
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(
request,
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)
@@ -2547,11 +2603,10 @@ class GenericViewBase:
)
class ExamCloneMixin():
class ExamCloneMixin:
def get_template_names(self) -> list[str]:
return "exam_clone_form.html"
#return super().get_template_names()
# return super().get_template_names()
def get_initial(self):
old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"])
@@ -2616,6 +2671,8 @@ class ExaminationAutocomplete(autocomplete.Select2QuerySetView):
return Examination.objects.none()
return qs
class SupervisorAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
# TODO: we should probably filter this to only
@@ -2745,6 +2802,7 @@ def cid_group_view_all(request):
{"groups": groups, "view_all": True},
)
@user_is_cid_user_manager
def user_group_add_candidates_to_exams(request, group_id):
if request.htmx:
@@ -2763,13 +2821,12 @@ def user_group_add_candidates_to_exams(request, group_id):
else:
exam.valid_user_users.add(*group.users.all())
return HttpResponse(f"Candidates added")
#exam.save()
# exam.save()
raise PermissionDenied() # or Http404
pass
@user_is_cid_user_manager
def user_group_view_detail(request, group_id):
group = get_object_or_404(UserUserGroup, pk=group_id)
@@ -2816,6 +2873,7 @@ def get_user_selection_from_request(request):
user_models = User.objects.filter(id__in=selected_users)
return user_models
@user_is_cid_user_manager
def user_not_trainee(request, user_id):
user = get_object_or_404(User, pk=user_id)
@@ -2823,7 +2881,10 @@ def user_not_trainee(request, user_id):
user.userprofile.peninsula_trainee = False
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
def users_bulk_edit(request):
@@ -2862,8 +2923,7 @@ def users_bulk_edit(request):
# TODO: work out how to actually use hyperscript
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-include="[name='selection']"
hx-confirm="This will remove grades of all selected users, are you sure you wish to continue?"
@@ -3064,6 +3124,7 @@ def candidate_email_results_resend(request, cid, resend=True):
def create_cid_email(request):
pass
@user_is_cid_user_manager
def cid_details(request, cid: int):
print(cid)
@@ -3076,6 +3137,7 @@ def cid_details(request, cid: int):
raise PermissionDenied() # or Http404
@user_is_cid_user_manager
def manage_cid_users(request):
if request.method == "POST":
@@ -3307,11 +3369,13 @@ class UserUserGroupUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView):
context["group_type"] = "user"
return context
class UserUserGroupDelete(RevisionMixin, CidManagerRequiredMixin, DeleteView):
model = UserUserGroup
template_name = "confirm_delete.html"
success_url = reverse_lazy("generic:user_group_view")
class UserGroupExamUpdate(CidManagerRequiredMixin, UpdateView):
model = UserUserGroup
form_class = UserGroupExamForm
@@ -3321,6 +3385,7 @@ class UserGroupExamUpdate(CidManagerRequiredMixin, UpdateView):
context["group_type"] = "user"
return context
class CidGroupExamUpdate(CidManagerRequiredMixin, UpdateView):
model = CidUserGroup
form_class = CidGroupExamForm
@@ -3330,6 +3395,7 @@ class CidGroupExamUpdate(CidManagerRequiredMixin, UpdateView):
context["group_type"] = "cid"
return context
class ExamCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
template_name = "exam_create_form.html"
@@ -3390,26 +3456,30 @@ def exam_inactive(request, context):
#
# success_url = reverse_lazy("accounts_list")
@user_is_cid_user_manager
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")
@user_is_cid_user_manager
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")
)
if grade is not None:
trainees = trainees.filter(grade__name=grade)
context = {
"trainees" : trainees,
"grade": grade
}
context = {"trainees": trainees, "grade": grade}
return render(request, "generic/trainees.html", context)
def create_trainee(request, context=None):
return create_user(request, context, trainee=True)
@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:
form_template = "generic/trainee_creation_form.html"
else:
@@ -3499,9 +3569,10 @@ class SupervisorCreate(CidManagerRequiredMixin, CreateView):
# success_url = reverse_lazy("generic:supervisor_detail")
#class SupervisorList(CidManagerRequiredMixin, ListView):
# class SupervisorList(CidManagerRequiredMixin, ListView):
# model = Supervisor
class SupervisorList(CidManagerRequiredMixin, SingleTableMixin, FilterView):
model = Supervisor
table_class = SupervisorTable
@@ -3509,20 +3580,25 @@ class SupervisorList(CidManagerRequiredMixin, SingleTableMixin, FilterView):
filterset_class = SupervisorFilter
class ExamCollectionList(ListView):
model = ExamCollection
class ExamCollectionDetail(DetailView, AuthorRequiredMixin):
model = ExamCollection
class ExamCollectionEdit(UpdateView, AuthorRequiredMixin):
model = ExamCollection
form_class = ExamCollectionForm
class ExamCollectionCreate(CreateView, AuthorRequiredMixin):
model = ExamCollection
form_class = ExamCollectionForm
class ExamCollectionClone(CreateView, AuthorRequiredMixin):
model = ExamCollection
template_name = "generic/examcollection_clone_form.html"
@@ -3532,23 +3608,22 @@ class ExamCollectionClone(CreateView, AuthorRequiredMixin):
old_object = get_object_or_404(self.model, pk=self.kwargs["pk"])
self.initial_object = old_object
initial_data = model_to_dict(old_object, exclude=["id", "date"])
## We manually transfer the forign keys / m2m relationships
#questions = old_object.exam_questions.all().values_list("id", flat=True)
# questions = old_object.exam_questions.all().values_list("id", flat=True)
authors = old_object.author.all().values_list("id", flat=True)
## clear the associated groups
#initial_data["valid_user_users"] = []
#initial_data["valid_cid_users"] = []
#initial_data["cid_user_groups"] = []
#initial_data["user_user_groups"] = []
# initial_data["valid_user_users"] = []
# initial_data["valid_cid_users"] = []
# initial_data["cid_user_groups"] = []
# initial_data["user_user_groups"] = []
#initial_data["active"] = False
#initial_data["publish_results"] = False
# initial_data["active"] = False
# initial_data["publish_results"] = False
#self.exam_questions = list(questions)
# self.exam_questions = list(questions)
self.author = list(authors)
return initial_data
@@ -3556,7 +3631,7 @@ class ExamCollectionClone(CreateView, AuthorRequiredMixin):
def form_valid(self, form):
object = form.save()
# Reapply these otherwise they get lost?
#object.exam_questions.set(self.exam_questions)
# object.exam_questions.set(self.exam_questions)
GROUP_TYPES = (
("Rapid Exams", "rapids_exams", RapidsExam),
@@ -3570,11 +3645,13 @@ class ExamCollectionClone(CreateView, AuthorRequiredMixin):
object.save()
return HttpResponseRedirect(object.get_absolute_url())
class ExamCollectionDelete(DeleteView, AuthorRequiredMixin):
model = ExamCollection
template_name = "confirm_delete.html"
success_url = reverse_lazy("generic:examcollection_list")
class ExaminationView(SuperuserRequiredMixin, SingleTableMixin, FilterView):
model = Examination
table_class = ExaminationTable
@@ -3596,7 +3673,8 @@ class ExaminationUpdate(UpdateView, SuperuserRequiredMixin):
class SeriesImagesZipViewBase(SuperuserRequiredMixin, View):
"""Download all images from an image series"""
http_method_names = ['get']
http_method_names = ["get"]
series_object = None
def get_files(self):
@@ -3605,12 +3683,14 @@ class SeriesImagesZipViewBase(SuperuserRequiredMixin, View):
return [i.image.file for i in series.get_images()]
def get_archive_name(self, request):
#series = Series.objects.get(pk=self.kwargs["pk"])
# series = Series.objects.get(pk=self.kwargs["pk"])
return f"Series {self.kwargs['pk']}.zip"
def get(self, request, *args, **kwargs):
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()
for file_ in files:
path = os.path.split(file_.name)[-1]
@@ -3619,24 +3699,35 @@ class SeriesImagesZipViewBase(SuperuserRequiredMixin, View):
file_size = temp_file.tell()
temp_file.seek(0)
response = HttpResponse(temp_file, content_type='application/zip')
response['Content-Disposition'] = 'attachment; filename=%s' % self.get_archive_name(request)
response['Content-Length'] = file_size
response = HttpResponse(temp_file, content_type="application/zip")
response["Content-Disposition"] = (
"attachment; filename=%s" % self.get_archive_name(request)
)
response["Content-Length"] = file_size
return response
class ExamGroupsUpdateBase(
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
RevisionMixin,
CheckCanEditMixin,
LoginRequiredMixin,
AuthorRequiredMixin,
UpdateView,
):
template_name = "generic/exam_groups_edit.html"
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):
kwargs = super(ExamGroupsUpdateBase, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView):
def get_form_kwargs(self):
@@ -3660,4 +3751,3 @@ class UpdateQuestionMixin(RedirectMixin, RevisionMixin, UpdateView):
if self.request.user in obj.get_author_objects():
return context
raise PermissionDenied() # or Http404
+26 -1
View File
@@ -268,7 +268,7 @@ button a {
#save-annotations {
position: absolute;
left: 0;
bottom: 0px;
}
#save-annotations.save-rapid-annotations {
@@ -1287,4 +1287,29 @@ tr:has(> td > a) {
/* For crispy... */
.select2-selection {
padding-bottom: 30px;
}
.help-text, .help-text summary {
cursor: help !important;
}
details.help-text > summary {
list-style: none;
}
/* .selector, .selector-available, .selector-chosen {
display: inline-block;
} */
#div_id_exams {
display: inline-block;
}
#dicom-image .help-text{
position: absolute;
z-index: 9999999;
}
#dicom-image .help-text:hover{
background-color: rgba(0, 0, 0, 0.546);
}
-3
View File
@@ -24,9 +24,6 @@ urlpatterns = [
views.QuestionDelete.as_view(),
name="question_delete",
),
# path("question/<int:pk>/update", views.QuestionUpdate.as_view(), name="anatomy_question_update"),
# path("exam/<int:pk>/<int:sk>/mark", views.mark, name="mark"),
# path("exam/<int:pk>/mark", views.mark_overview, name="mark_overview"),
path(
"exam/<int:pk>/<int:sk>/<str:cid>/<str:passcode>/take",
views.exam_take,