Compare commits
17
Commits
9c61068026
...
3f5f39c41c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f5f39c41c | ||
|
|
92663bc620 | ||
|
|
359a399cc8 | ||
|
|
883b11a689 | ||
|
|
2e22846d23 | ||
|
|
ef8d8f3ef1 | ||
|
|
51029d1b83 | ||
|
|
1d04f9ae9f | ||
|
|
48e6339ecb | ||
|
|
3616d1b9c5 | ||
|
|
41dd5b7086 | ||
|
|
707a98629d | ||
|
|
8833fb6105 | ||
|
|
e6d8317fc5 | ||
|
|
d1249f14d8 | ||
|
|
6f96f79303 | ||
|
|
ea42f7f0c7 |
+15
-4
@@ -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
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user