From 9f54a9c108490d208434dd74a850ea59c485bb3a Mon Sep 17 00:00:00 2001 From: Ross Date: Wed, 23 Mar 2022 22:06:33 +0000 Subject: [PATCH] . --- anatomy/forms.py | 430 +-- anatomy/static/css/anatomy.css | 1660 ++++----- anatomy/static/js/anatomy.js | 1053 +++--- anatomy/templates/anatomy/mark_overview.html | 38 +- anatomy/views.py | 2222 +++++------ atlas/admin.py | 224 +- atlas/migrations/0025_auto_20220321_1602.py | 72 +- atlas/migrations/0026_auto_20220321_1612.py | 46 +- atlas/models.py | 1143 +++--- atlas/templates/atlas/case_display_block.html | 250 +- .../templates/atlas/collection_case_view.html | 92 +- .../atlas/collection_detail_view.html | 22 +- .../atlas/collection_index_view.html | 16 +- .../atlas/collection_series_viewer.html | 342 +- atlas/urls.py | 302 +- atlas/views.py | 1836 ++++----- generic/forms.py | 376 +- .../migrations/0025_alter_ciduser_group.py | 38 +- generic/models.py | 938 ++--- generic/views.py | 3300 ++++++++--------- longs/forms.py | 560 +-- longs/templates/longs/base.html | 64 +- longs/views.py | 2678 ++++++------- rad/settings.py | 536 +-- rapids/forms.py | 482 +-- rapids/templates/rapids/mark_overview.html | 38 +- rapids/views.py | 2580 ++++++------- sbas/templates/sbas/exam_form.html | 32 +- sbas/urls.py | 154 +- sbas/views.py | 820 ++-- static/css/anatomy.css | 1660 ++++----- templates/exam_overview_js.html | 322 +- 32 files changed, 12173 insertions(+), 12153 deletions(-) diff --git a/anatomy/forms.py b/anatomy/forms.py index b749719f..c795fae5 100644 --- a/anatomy/forms.py +++ b/anatomy/forms.py @@ -1,215 +1,215 @@ -from django.forms import ( - Form, - ModelForm, - ModelMultipleChoiceField, - ModelChoiceField, - ChoiceField, - CharField, -) -from django.forms import inlineformset_factory - -from .models import ( - Answer, - CidUserAnswer, - AnatomyQuestion, - # Examination, - BodyPart, - Structure, - Region, - Modality, - QuestionType, - Exam, -) -from generic.models import Examination - -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.forms.widgets import RadioSelect, TextInput, Textarea - -from dal import autocomplete - - -class AnatomyAnswerForm(ModelForm): - class Meta: - model = CidUserAnswer - fields = ("answer",) - - -class MarkAnatomyQuestionForm(Form): - # correct = forms.CharField(required=False) - # half_correct = forms.CharField(required=False) - # incorrect = forms.CharField(required=False) - marked_answers = CharField(required=False) - - -class AnatomyQuestionForm(ModelForm): - - # exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all()) - - class Media: - # Django also includes a few javascript files necessary - # for the operation of this form element. You need to - # include - # in the template. - css = { - "all": ["css/widgets.css"], - } - # Adding this javascript is crucial - js = ["jsi18n.js", "tesseract.min.js"] - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop( - "user" - ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole - if kwargs.get("instance"): - # We get the 'initial' keyword argument or initialize it - # as a dict if it didn't exist. - initial = kwargs.setdefault("initial", {}) - # The widget for a ModelMultipleChoiceField expects - # a list of primary key for the selected data. - initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] - - ModelForm.__init__(self, *args, **kwargs) - - super(AnatomyQuestionForm, self).__init__(*args, **kwargs) - # 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( - required=True, - queryset=QuestionType.objects.all(), - initial=1, - ) - - self.fields["region"] = ModelChoiceField( - required=False, - queryset=Region.objects.all(), - ) - - self.fields["modality"] = ModelChoiceField( - required=True, - queryset=Modality.objects.all(), - ) - - self.fields["examination"] = ModelChoiceField( - required=False, - queryset=Examination.objects.all(), - ) - - self.fields["body_part"] = ModelChoiceField( - required=False, - queryset=BodyPart.objects.all(), - ) - - if self.user.groups.filter(name="anatomy_checker").exists(): - exam_queryset = Exam.objects.all() - else: - exam_queryset = Exam.objects.filter( - author__id=self.user.id - ) | Exam.objects.filter(open_access=True) - - self.fields["exams"] = ModelMultipleChoiceField( - required=False, - queryset=exam_queryset, - widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), - ) - - def save(self, commit=True): - # Get the unsaved Pizza instance - instance = ModelForm.save(self, False) - instance.save() - - old_exams = instance.exams.all() - - new_exams = self.cleaned_data["exams"] - - for exam in old_exams: - if exam not in new_exams: - exam.exam_questions.remove(instance) - - for exam in new_exams: - exam.exam_questions.add(instance) - - self.save_m2m() - - return instance - - class Meta: - model = AnatomyQuestion - - fields = [ - "question_type", - "image", - "region", - "modality", - "structure", - "examination", - "body_part", - "description", - "open_access", - ] - - widgets = { - "structure": autocomplete.ModelSelect2(url="anatomy:structure-autocomplete") - } - - -AnswerFormSet = inlineformset_factory( - AnatomyQuestion, - Answer, - fields=["answer", "status"], - widgets={ - "answer": Textarea( - attrs={"required": "true", "minlength": 2, "maxlength": 500, "rows": 3} - ) - }, - exclude=[], - can_delete=True, - extra=1, - max_num=10, -) - -AnswerUpdateFormSet = inlineformset_factory( - AnatomyQuestion, - Answer, - fields=["answer", "status"], - widgets={ - "answer": Textarea( - attrs={"required": "true", "minlength": 2, "maxlength": 500, "rows": 3} - ) - }, - exclude=[], - can_delete=True, - extra=0, - max_num=10, -) - - -class ExaminationForm(ModelForm): - class Meta: - model = Examination - fields = ["examination"] - - -class StructureForm(ModelForm): - class Meta: - model = Structure - fields = ["structure"] - - -class BodyPartForm(ModelForm): - class Meta: - model = BodyPart - fields = ["bodypart"] - - -class ExamForm(ModelForm): - class Meta: - model = Exam - fields = [ - "name", - "time_limit", - "open_access", - "exam_mode", - "active", - "publish_results", - "archive", - ] +from django.forms import ( + Form, + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, + CharField, +) +from django.forms import inlineformset_factory + +from .models import ( + Answer, + CidUserAnswer, + AnatomyQuestion, + # Examination, + BodyPart, + Structure, + Region, + Modality, + QuestionType, + Exam, +) +from generic.models import Examination + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms.widgets import RadioSelect, TextInput, Textarea + +from dal import autocomplete + + +class AnatomyAnswerForm(ModelForm): + class Meta: + model = CidUserAnswer + fields = ("answer",) + + +class MarkAnatomyQuestionForm(Form): + # correct = forms.CharField(required=False) + # half_correct = forms.CharField(required=False) + # incorrect = forms.CharField(required=False) + marked_answers = CharField(required=False) + + +class AnatomyQuestionForm(ModelForm): + + # exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all()) + + class Media: + # Django also includes a few javascript files necessary + # for the operation of this form element. You need to + # include + # in the template. + css = { + "all": ["css/widgets.css"], + } + # Adding this javascript is crucial + js = ["jsi18n.js", "tesseract.min.js"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop( + "user" + ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] + + ModelForm.__init__(self, *args, **kwargs) + + super(AnatomyQuestionForm, self).__init__(*args, **kwargs) + # 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( + required=True, + queryset=QuestionType.objects.all(), + initial=1, + ) + + self.fields["region"] = ModelChoiceField( + required=False, + queryset=Region.objects.all(), + ) + + self.fields["modality"] = ModelChoiceField( + required=True, + queryset=Modality.objects.all(), + ) + + self.fields["examination"] = ModelChoiceField( + required=False, + queryset=Examination.objects.all(), + ) + + self.fields["body_part"] = ModelChoiceField( + required=False, + queryset=BodyPart.objects.all(), + ) + + if self.user.groups.filter(name="anatomy_checker").exists(): + exam_queryset = Exam.objects.all() + else: + exam_queryset = Exam.objects.filter( + author__id=self.user.id + ) | Exam.objects.filter(open_access=True) + + self.fields["exams"] = ModelMultipleChoiceField( + required=False, + queryset=exam_queryset, + widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), + ) + + def save(self, commit=True): + # Get the unsaved Pizza instance + instance = ModelForm.save(self, False) + instance.save() + + old_exams = instance.exams.all() + + new_exams = self.cleaned_data["exams"] + + for exam in old_exams: + if exam not in new_exams: + exam.exam_questions.remove(instance) + + for exam in new_exams: + exam.exam_questions.add(instance) + + self.save_m2m() + + return instance + + class Meta: + model = AnatomyQuestion + + fields = [ + "question_type", + "image", + "region", + "modality", + "structure", + "examination", + "body_part", + "description", + "open_access", + ] + + widgets = { + "structure": autocomplete.ModelSelect2(url="anatomy:structure-autocomplete") + } + + +AnswerFormSet = inlineformset_factory( + AnatomyQuestion, + Answer, + fields=["answer", "status"], + widgets={ + "answer": Textarea( + attrs={"required": "true", "minlength": 2, "maxlength": 500, "rows": 3} + ) + }, + exclude=[], + can_delete=True, + extra=1, + max_num=10, +) + +AnswerUpdateFormSet = inlineformset_factory( + AnatomyQuestion, + Answer, + fields=["answer", "status"], + widgets={ + "answer": Textarea( + attrs={"required": "true", "minlength": 2, "maxlength": 500, "rows": 3} + ) + }, + exclude=[], + can_delete=True, + extra=0, + max_num=10, +) + + +class ExaminationForm(ModelForm): + class Meta: + model = Examination + fields = ["examination"] + + +class StructureForm(ModelForm): + class Meta: + model = Structure + fields = ["structure"] + + +class BodyPartForm(ModelForm): + class Meta: + model = BodyPart + fields = ["bodypart"] + + +class ExamForm(ModelForm): + class Meta: + model = Exam + fields = [ + "name", + "time_limit", + "open_access", + "exam_mode", + "active", + "publish_results", + "archive", + ] diff --git a/anatomy/static/css/anatomy.css b/anatomy/static/css/anatomy.css index f6f0033f..8ae9a1f9 100644 --- a/anatomy/static/css/anatomy.css +++ b/anatomy/static/css/anatomy.css @@ -1,831 +1,831 @@ -body { - color: gray; - background-color: black; -} - -a, -a:link { - color: #3282b8; -} - -.warn { - color: red; -} - -.answer-list { - font-size: 20px; - user-select: none; - -webkit-user-select: none; - /* Chrome/Safari */ - -moz-user-select: none; - /* Firefox */ - -ms-user-select: none; - /* IE10+ */ -} - -.answer-list .answer:hover { - z-index: 99999; - position: relative; - background: black; -} - -.answer-list .correct { - /* color: green; */ - color: lightblue; -} - -.answer-list .correct::after { - content: " [Score = 2]"; - font-size: small; -} - -.answer-list.rapid .correct::after { - content: " [Score = 2]"; -} - -.answer-list .half-correct { - color: yellow; -} - -.answer-list .half-correct::after { - content: " [Score = 1]"; - font-size: small; -} - -.answer-list.rapid .half-correct::after { - content: " [Score = 1]"; -} - -.answer-list .incorrect { - color: red; -} - -.answer-list .incorrect::after { - content: " [Score = 0]"; - font-size: small; -} - -.answer-list .not-marked { - color: white; -} - -.key { - font-size: 20; -} - -.hide { - display: none; -} - -.answered { - /* color: green; */ - color: lightblue; -} - -.unanswered { - color: gray; -} - -#question-list { - list-style: none; - float: right; - padding: 1px; - border: 1px solid gray; - /*max-height: 80%;*/ - height: 100%; - overflow-y: scroll; -} - -.flagged { - color: red; -} - -.not-flagged {} - -button { - text-decoration: none; - display: inline-block; - padding: 0.35em 1.2em; - border: 0.1em solid #52057b; - margin: 0 0.3em 0.3em 0; - border-radius: 0.12em; - box-sizing: border-box; - text-decoration: none; - font-family: 'Roboto', sans-serif; - font-weight: 300; - color: #a600ff; - text-align: center; - transition: all 0.2s; - background-color: transparent; -} - -button:hover { - color: white; - background-color: #52057b; -} - -button a { - text-decoration: none; - color: inherit; -} - -.main-nav-header { - /* max-width: 50%; */ - float: right; -} - -.main-nav-header span { - float: right; - padding-left: 10px; -} - -.top-bar-link {} - -.marking-list pre { - display: inline; -} - -.save { - display: inline-block; - padding: 0.35em 1.2em; - border: 0.1em solid #ffffff; - margin: 0 0.3em 0.3em 0; - border-radius: 0.12em; - box-sizing: border-box; - text-decoration: none; - font-family: "Roboto", sans-serif; - font-weight: 300; - color: #ffffff; - text-align: center; - transition: all 0.2s; - background-color: transparent; -} - -.save:hover { - color: #000000; - background-color: #ffffff; -} - -#stats-plot { - width: 50%; - float: right; -} - -#dicom-image { - width: 100%; - height: 600px; -} - -.dicom-viewer { - width: 100%; - height: 600px; -} - -.rapid-dicom-viewer { - width: 400px; - height: 600px; - position: fixed; - top: 30px; - right: 0px; - float: right; -} - -.marking-dicom { - position: fixed; - right: 0px; - bottom: 0px; - width: 45%; - height: 90%; - z-index: 1; -} - -.cornerstone-canvas { - height: 100%; -} - -.marking {} - -#question-mark-list { - padding: 20px -} - -#full-question-list { - counter-reset: question-list; - /* Set a counter named 'section', and its initial value is 0. */ -} - -#full-question-list li { - padding-bottom: 20px; -} - -#full-question-list li::before { - counter-increment: question-list; - content: counter(question-list) " "; - padding-right: 0.5em; -} - -#full-question-list img { - float: left; - padding-right: 20px; - padding-left: 10px; -} - -#question-mark-list a { - color: darkgray; -} - -#question-mark-list .unmarked a { - color: purple; -} - -#question-mark-list li { - padding-bottom: 5px; -} - -#start-marking-button { - float: right; - position: sticky; - top: 30px; -} - -#save-annotations { - position: absolute; - left: 0; -} - -.drop-target { - width: 100%; - width: 100vw; - height: 100px; - text-align: center; - font-size: larger; - position: sticky; - bottom: 0; - left: 0%; - right: 50%; - margin-left: -50vw; - margin-right: -50vw; -} - -#drop-container { - border: 1px dashed white; -} - -.submit-button { - position: sticky; - bottom: 0; -} - -#feedback-drop-target { - float: right; - height: 100%; - width: 30%; -} - -.drop-target-active { - background-color: #427aa1; -} - -#drop-filenames span { - font-size: xx-small; - color: gray; - padding-right: 1em; - -} - -.image-formset { - border: 1px solid white; -} - -select, -input { - background-color: #05668d; - color: white; - border: none; -} - -table.table { - color: white; -} - -#view-filter-options { - /* float: right; */ -} - -.rapid-img { - width: 300px; - height: 300px; -} - -.feedback-img { - border: 1px solid #05668d; -} - -.uploading { - margin-top: -20px; - height: 75%; - /* float: right; */ - position: absolute; - display: inline; - max-width: 100px; -} - -/* img.uploading:hover { - height: 95%; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%) -} */ - -.image-ident-warning { - border: 2px solid red; -} - - -.image-ident-loading { - background: rgba(142, 68, 173, 1); - box-shadow: 0 0 0 0 rgba(142, 68, 173, 1); - animation: pulse-purple 2s infinite; -} - -@keyframes pulse-purple { - 0% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(142, 68, 173, 0.7); - } - - 70% { - transform: scale(1); - box-shadow: 0 0 0 10px rgba(142, 68, 173, 0); - } - - 100% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(142, 68, 173, 0); - } -} - -#annotation-json-content { - display: none; -} - -.cid-score-table { - max-width: 400px; -} - -.user-answer-score-0 { - color: red; -} - -.user-answer-score-1 { - color: yellow; -} - -.user-answer-score-2 { - /* color: green; */ - color: lightblue; -} - -.rapid-ans.user-answer-score-2 { - color: lightblue; -} - -.rapid-ans.user-answer-score-1 { - /* color: green; */ - color: yellow; -} - -.physics-ans .user-answer-score-1 { - color: lightblue; -} - -td.user-answer-score-0::after { - content: "✗"; -} - -td.user-answer-score-1::after { - content: "✓"; -} - -td.user-answer-score-2::after { - content: "✓✓"; -} - -td.user-answer-score-1.rapid-ans::after { - content: "✓"; -} - -td.user-answer-score-2.rapid-ans::after { - content: "✓✓"; -} - -.score-row { - font-weight: bolder; - background-color: darkslateblue; -} - -.user-answer-score pre { - display: inline; - color: inherit; -} - -.correct-answer { - color: white; -} - -.user-answer-li { - padding-top: 10px; -} - -.parent-help:hover .help-text { - opacity: 50%; -} - -.parent-help .help-text { - opacity: 0%; -} - -.google-link { - padding-left: 10px; - font-size: smaller; -} - -.abcde { - list-style-type: lower-alpha; -} - -.abcde li { - padding: 0px; - clear: right; - vertical-align: text-top; -} - -#full-question-list-physics li { - padding-bottom: 10px; -} - - -#full-question-list-physics { - width: 800px; - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; -} - -.answer-1 { - /* color: green; */ - color: lightblue; -} - -.answer-0 { - color: red; -} - -.selector { - border-color: #05668d; -} - -.selector input { - color: gray; - background-color: white; - font-size: small; -} - -.selector .selector-available h2, -.selector .selector-chosen h2 { - margin-bottom: 0px; - border: none; - /* border-color: #05668d; */ - background-color: #ebf2fa; - color: #343a40; - font-size: larger; -} - -.selector .selector-available select, -.selector .selector-chosen select { - border-color: #05668d; -} - -#id_laterality { - margin-top: 30px; -} - -#id_laterality li { - display: inline-block; - border-radius: 4px; - text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); - background: rgb(28, 184, 65); - padding-right: 10px; - color: white; -} - -.add-popup { - display: block; -} - -.multi-image-block, -.series-block { - display: inline-flex; -} - -.sortable-placeholder { - border: 4px solid purple; - -} - -.temp-thumb { - float: right; - height: 100px; - width: 100px; -} - -.long-answer pre { - white-space: pre-wrap; - font: inherit; - color: inherit; -} - -.long-answer .answer-heading { - font-style: italic; - font-weight: bold; -} - -.proposed-answer { - color: darkblue -} - -.notes .complete { - color: darkslategray; -} - -table.longs .user-answer-score-4, -table.longs .user-answer-score-4\.0, -table.longs .user-answer-score-4\.5 { - color: red; -} - -table.longs .user-answer-score-5\.0, -table.longs .user-answer-score-5\.5 { - color: yellow; -} - -table.longs .user-answer-score-6\.0, -table.longs .user-answer-score-6\.5 { - color: yellowgreen; -} - -table.longs .user-answer-score-7\.0, -table.longs .user-answer-score-7\.5, -table.longs .user-answer-score-8\.0, -table.longs .user-answer-score-8, -table.longs .user-answer-score-8\.5 { - color: green; -} - -table.longs .user-answer-score- { - background-color: red; -} - -textarea, -input { - border-radius: 2px; - background-color: #343a40; - color: lightgray; -} - -.exam-list li, -#full-question-list li { - display: flex; -} - -.flex-col { - flex: 1; -} - -.flex-col-2 { - flex: 2; -} - -.flex-col-3 { - flex: 3; -} - -.flex-col-4 { - flex: 4; -} - -.score-answer-list { - width: 55%; -} - -.score-answer-list pre { - white-space: pre-wrap; -} - -.question-display-block { - position: fixed; - right: 0px; - top: 30px; - width: 40%; -} - -.view-question-link { - float: right; - color: blue; - cursor: pointer; - text-decoration: underline; -} - -.view-question-link-longs { - color: blue; - cursor: pointer; - text-decoration: underline; -} - -.date { - font-size: smaller; - opacity: 50%; -} - -.date:hover { - opacity: 100%; -} - -.floating-header { - position: sticky; - top: 1px; - background-color: black; - border: dashed 1px gray; - padding-left: 1em; - z-index: 999; -} - -.sba-answer-list { - list-style-type: lower-alpha; -} - -.sba-answer-list li:hover { - color: purple; -} - -#menu-list button { - padding-left: 2px; - padding-right: 2px; - padding-top: 0px; - padding-bottom: 0px; - margin: 1px; -} - -.sba-finish-list { - /* display: flex; */ -} - -/* .sba-finish-list li { - display: inline-block; - border: 1px solid purple; - min-width: 10px; -} -*/ - -.sba-finish-list a button.unanswered { - color: red; - border-style: dashed; -} - -.no-select { - user-select: none; -} - -.sba-answer-list .correct { - background-color: green; - color: white; -} - -.id { - opacity: 50%; - font-size: small; -} - -.sticky-alert { - position: sticky; - top: 5px; -} - -.marking-block { - display: inline-flex; - flex-wrap: wrap; -} - -.marking-block details { - padding-right: 20px; - flex: 1 -} - -.image-link { - color: purple; - text-decoration: underline; - cursor: pointer; -} - -.inner-display-block { - display: none; -} - -.hide-show { - float: right; - transform: rotate(90deg) translateX(10px); - color: blueviolet; - user-select: none; -} - -.clear-both { - clear: both; -} - -.check-review { - color: purple; -} - -.current-mark { - color: gray; -} - -.select2-dropdown { - background-color: #05668d; - color: white; - border-color: #05668d; -} - -.select2-selection .select2-selection--multiple, -.select2 .select2-selection--single, -.select2-selection { - background-color: #05668d; - color: white; - /* border: 1px solid #aaa; */ - border-radius: 0px; -} - -.select2-search__field { - background-color: #05668d; - color: darkblue; - border: 1px solid black; - -} - -.select2-container--default.select2-container--focus .select2-selection--multiple, -.select2-container--default.select2-container--default .select2-selection--multiple, -.select2-selection .select2-selection__rendered .select2-selection__choice { - background-color: #5897fb; -} - -.select2-selection__rendered { - user-select: none; -} - -.temp-thumb-large { - width: 1000px; - height: 1000px; - max-width: 1000px; -} - -.image-duplicate { - border: 2px solid orange; -} - -.finding-form { - border: 1px dashed darkblue; - -} - -.finding-box { - display: flex; - border: 1px dashed gray; -} - -.view-finding-button { - flex: 1; -} - -.view-finding-details { - flex: 4; -} - -.delete-finding-link, -.edit-finding-link { - flex: 1; -} - -summary h2, -summary h3, -summary h4, -summary h5 { - display: inline; -} - -.mark-buttons { - position: sticky; - bottom: 0px; - background-color: black; - opacity: 80%; +body { + color: gray; + background-color: black; +} + +a, +a:link { + color: #3282b8; +} + +.warn { + color: red; +} + +.answer-list { + font-size: 20px; + user-select: none; + -webkit-user-select: none; + /* Chrome/Safari */ + -moz-user-select: none; + /* Firefox */ + -ms-user-select: none; + /* IE10+ */ +} + +.answer-list .answer:hover { + z-index: 99999; + position: relative; + background: black; +} + +.answer-list .correct { + /* color: green; */ + color: lightblue; +} + +.answer-list .correct::after { + content: " [Score = 2]"; + font-size: small; +} + +.answer-list.rapid .correct::after { + content: " [Score = 2]"; +} + +.answer-list .half-correct { + color: yellow; +} + +.answer-list .half-correct::after { + content: " [Score = 1]"; + font-size: small; +} + +.answer-list.rapid .half-correct::after { + content: " [Score = 1]"; +} + +.answer-list .incorrect { + color: red; +} + +.answer-list .incorrect::after { + content: " [Score = 0]"; + font-size: small; +} + +.answer-list .not-marked { + color: white; +} + +.key { + font-size: 20; +} + +.hide { + display: none; +} + +.answered { + /* color: green; */ + color: lightblue; +} + +.unanswered { + color: gray; +} + +#question-list { + list-style: none; + float: right; + padding: 1px; + border: 1px solid gray; + /*max-height: 80%;*/ + height: 100%; + overflow-y: scroll; +} + +.flagged { + color: red; +} + +.not-flagged {} + +button { + text-decoration: none; + display: inline-block; + padding: 0.35em 1.2em; + border: 0.1em solid #52057b; + margin: 0 0.3em 0.3em 0; + border-radius: 0.12em; + box-sizing: border-box; + text-decoration: none; + font-family: 'Roboto', sans-serif; + font-weight: 300; + color: #a600ff; + text-align: center; + transition: all 0.2s; + background-color: transparent; +} + +button:hover { + color: white; + background-color: #52057b; +} + +button a { + text-decoration: none; + color: inherit; +} + +.main-nav-header { + /* max-width: 50%; */ + float: right; +} + +.main-nav-header span { + float: right; + padding-left: 10px; +} + +.top-bar-link {} + +.marking-list pre { + display: inline; +} + +.save { + display: inline-block; + padding: 0.35em 1.2em; + border: 0.1em solid #ffffff; + margin: 0 0.3em 0.3em 0; + border-radius: 0.12em; + box-sizing: border-box; + text-decoration: none; + font-family: "Roboto", sans-serif; + font-weight: 300; + color: #ffffff; + text-align: center; + transition: all 0.2s; + background-color: transparent; +} + +.save:hover { + color: #000000; + background-color: #ffffff; +} + +#stats-plot { + width: 50%; + float: right; +} + +#dicom-image { + width: 100%; + height: 600px; +} + +.dicom-viewer { + width: 100%; + height: 600px; +} + +.rapid-dicom-viewer { + width: 400px; + height: 600px; + position: fixed; + top: 30px; + right: 0px; + float: right; +} + +.marking-dicom { + position: fixed; + right: 0px; + bottom: 0px; + width: 45%; + height: 90%; + z-index: 1; +} + +.cornerstone-canvas { + height: 100%; +} + +.marking {} + +#question-mark-list { + padding: 20px +} + +#full-question-list { + counter-reset: question-list; + /* Set a counter named 'section', and its initial value is 0. */ +} + +#full-question-list li { + padding-bottom: 20px; +} + +#full-question-list li::before { + counter-increment: question-list; + content: counter(question-list) " "; + padding-right: 0.5em; +} + +#full-question-list img { + float: left; + padding-right: 20px; + padding-left: 10px; +} + +#question-mark-list a { + color: darkgray; +} + +#question-mark-list .unmarked a { + color: purple; +} + +#question-mark-list li { + padding-bottom: 5px; +} + +#start-marking-button { + float: right; + position: sticky; + top: 30px; +} + +#save-annotations { + position: absolute; + left: 0; +} + +.drop-target { + width: 100%; + width: 100vw; + height: 100px; + text-align: center; + font-size: larger; + position: sticky; + bottom: 0; + left: 0%; + right: 50%; + margin-left: -50vw; + margin-right: -50vw; +} + +#drop-container { + border: 1px dashed white; +} + +.submit-button { + position: sticky; + bottom: 0; +} + +#feedback-drop-target { + float: right; + height: 100%; + width: 30%; +} + +.drop-target-active { + background-color: #427aa1; +} + +#drop-filenames span { + font-size: xx-small; + color: gray; + padding-right: 1em; + +} + +.image-formset { + border: 1px solid white; +} + +select, +input { + background-color: #05668d; + color: white; + border: none; +} + +table.table { + color: white; +} + +#view-filter-options { + /* float: right; */ +} + +.rapid-img { + width: 300px; + height: 300px; +} + +.feedback-img { + border: 1px solid #05668d; +} + +.uploading { + margin-top: -20px; + height: 75%; + /* float: right; */ + position: absolute; + display: inline; + max-width: 100px; +} + +/* img.uploading:hover { + height: 95%; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) +} */ + +.image-ident-warning { + border: 2px solid red; +} + + +.image-ident-loading { + background: rgba(142, 68, 173, 1); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 1); + animation: pulse-purple 2s infinite; +} + +@keyframes pulse-purple { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(142, 68, 173, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 0); + } +} + +#annotation-json-content { + display: none; +} + +.cid-score-table { + max-width: 400px; +} + +.user-answer-score-0 { + color: red; +} + +.user-answer-score-1 { + color: yellow; +} + +.user-answer-score-2 { + /* color: green; */ + color: lightblue; +} + +.rapid-ans.user-answer-score-2 { + color: lightblue; +} + +.rapid-ans.user-answer-score-1 { + /* color: green; */ + color: yellow; +} + +.physics-ans .user-answer-score-1 { + color: lightblue; +} + +td.user-answer-score-0::after { + content: "✗"; +} + +td.user-answer-score-1::after { + content: "✓"; +} + +td.user-answer-score-2::after { + content: "✓✓"; +} + +td.user-answer-score-1.rapid-ans::after { + content: "✓"; +} + +td.user-answer-score-2.rapid-ans::after { + content: "✓✓"; +} + +.score-row { + font-weight: bolder; + background-color: darkslateblue; +} + +.user-answer-score pre { + display: inline; + color: inherit; +} + +.correct-answer { + color: white; +} + +.user-answer-li { + padding-top: 10px; +} + +.parent-help:hover .help-text { + opacity: 50%; +} + +.parent-help .help-text { + opacity: 0%; +} + +.google-link { + padding-left: 10px; + font-size: smaller; +} + +.abcde { + list-style-type: lower-alpha; +} + +.abcde li { + padding: 0px; + clear: right; + vertical-align: text-top; +} + +#full-question-list-physics li { + padding-bottom: 10px; +} + + +#full-question-list-physics { + width: 800px; + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; +} + +.answer-1 { + /* color: green; */ + color: lightblue; +} + +.answer-0 { + color: red; +} + +.selector { + border-color: #05668d; +} + +.selector input { + color: gray; + background-color: white; + font-size: small; +} + +.selector .selector-available h2, +.selector .selector-chosen h2 { + margin-bottom: 0px; + border: none; + /* border-color: #05668d; */ + background-color: #ebf2fa; + color: #343a40; + font-size: larger; +} + +.selector .selector-available select, +.selector .selector-chosen select { + border-color: #05668d; +} + +#id_laterality { + margin-top: 30px; +} + +#id_laterality li { + display: inline-block; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + background: rgb(28, 184, 65); + padding-right: 10px; + color: white; +} + +.add-popup { + display: block; +} + +.multi-image-block, +.series-block { + display: inline-flex; +} + +.sortable-placeholder { + border: 4px solid purple; + +} + +.temp-thumb { + float: right; + height: 100px; + width: 100px; +} + +.long-answer pre { + white-space: pre-wrap; + font: inherit; + color: inherit; +} + +.long-answer .answer-heading { + font-style: italic; + font-weight: bold; +} + +.proposed-answer { + color: darkblue +} + +.notes .complete { + color: darkslategray; +} + +table.longs .user-answer-score-4, +table.longs .user-answer-score-4\.0, +table.longs .user-answer-score-4\.5 { + color: red; +} + +table.longs .user-answer-score-5\.0, +table.longs .user-answer-score-5\.5 { + color: yellow; +} + +table.longs .user-answer-score-6\.0, +table.longs .user-answer-score-6\.5 { + color: yellowgreen; +} + +table.longs .user-answer-score-7\.0, +table.longs .user-answer-score-7\.5, +table.longs .user-answer-score-8\.0, +table.longs .user-answer-score-8, +table.longs .user-answer-score-8\.5 { + color: green; +} + +table.longs .user-answer-score- { + background-color: red; +} + +textarea, +input { + border-radius: 2px; + background-color: #343a40; + color: lightgray; +} + +.exam-list li, +#full-question-list li { + display: flex; +} + +.flex-col { + flex: 1; +} + +.flex-col-2 { + flex: 2; +} + +.flex-col-3 { + flex: 3; +} + +.flex-col-4 { + flex: 4; +} + +.score-answer-list { + width: 55%; +} + +.score-answer-list pre { + white-space: pre-wrap; +} + +.question-display-block { + position: fixed; + right: 0px; + top: 30px; + width: 40%; +} + +.view-question-link { + float: right; + color: blue; + cursor: pointer; + text-decoration: underline; +} + +.view-question-link-longs { + color: blue; + cursor: pointer; + text-decoration: underline; +} + +.date { + font-size: smaller; + opacity: 50%; +} + +.date:hover { + opacity: 100%; +} + +.floating-header { + position: sticky; + top: 1px; + background-color: black; + border: dashed 1px gray; + padding-left: 1em; + z-index: 999; +} + +.sba-answer-list { + list-style-type: lower-alpha; +} + +.sba-answer-list li:hover { + color: purple; +} + +#menu-list button { + padding-left: 2px; + padding-right: 2px; + padding-top: 0px; + padding-bottom: 0px; + margin: 1px; +} + +.sba-finish-list { + /* display: flex; */ +} + +/* .sba-finish-list li { + display: inline-block; + border: 1px solid purple; + min-width: 10px; +} +*/ + +.sba-finish-list a button.unanswered { + color: red; + border-style: dashed; +} + +.no-select { + user-select: none; +} + +.sba-answer-list .correct { + background-color: green; + color: white; +} + +.id { + opacity: 50%; + font-size: small; +} + +.sticky-alert { + position: sticky; + top: 5px; +} + +.marking-block { + display: inline-flex; + flex-wrap: wrap; +} + +.marking-block details { + padding-right: 20px; + flex: 1 +} + +.image-link { + color: purple; + text-decoration: underline; + cursor: pointer; +} + +.inner-display-block { + display: none; +} + +.hide-show { + float: right; + transform: rotate(90deg) translateX(10px); + color: blueviolet; + user-select: none; +} + +.clear-both { + clear: both; +} + +.check-review { + color: purple; +} + +.current-mark { + color: gray; +} + +.select2-dropdown { + background-color: #05668d; + color: white; + border-color: #05668d; +} + +.select2-selection .select2-selection--multiple, +.select2 .select2-selection--single, +.select2-selection { + background-color: #05668d; + color: white; + /* border: 1px solid #aaa; */ + border-radius: 0px; +} + +.select2-search__field { + background-color: #05668d; + color: darkblue; + border: 1px solid black; + +} + +.select2-container--default.select2-container--focus .select2-selection--multiple, +.select2-container--default.select2-container--default .select2-selection--multiple, +.select2-selection .select2-selection__rendered .select2-selection__choice { + background-color: #5897fb; +} + +.select2-selection__rendered { + user-select: none; +} + +.temp-thumb-large { + width: 1000px; + height: 1000px; + max-width: 1000px; +} + +.image-duplicate { + border: 2px solid orange; +} + +.finding-form { + border: 1px dashed darkblue; + +} + +.finding-box { + display: flex; + border: 1px dashed gray; +} + +.view-finding-button { + flex: 1; +} + +.view-finding-details { + flex: 4; +} + +.delete-finding-link, +.edit-finding-link { + flex: 1; +} + +summary h2, +summary h3, +summary h4, +summary h5 { + display: inline; +} + +.mark-buttons { + position: sticky; + bottom: 0px; + background-color: black; + opacity: 80%; } \ No newline at end of file diff --git a/anatomy/static/js/anatomy.js b/anatomy/static/js/anatomy.js index 3959803f..fdfb4c72 100644 --- a/anatomy/static/js/anatomy.js +++ b/anatomy/static/js/anatomy.js @@ -1,527 +1,528 @@ -import * as dicomViewer from "./dicomViewer.js" - -window.dicomViewer = dicomViewer; - -window.marked_answers = { - "correct": [], - "half-correct": [], - "incorrect": [], -} - -window.control_pressed = false; - - -$(document).ready(function () { - $("table thead th input").click((e) => { - let status = e.currentTarget.checked; - $("table tbody input").prop("checked", status); - }) - - //$(".answer-list li").each(function (index, element) { - // $(element).append($("G")); - //}); - - $(".answer-list span.answer").each(function (index, element) { - console.log(element); - - $(element).click(function (e) { - - var classes = ['answer correct', 'answer half-correct', 'answer incorrect']; - $(element).each(function () { - let mark = classes[($.inArray(this.className, classes) + 1) % classes.length]; - this.className = mark - this.dataset.newmark = mark.split(" ")[1] - }); - - prepAnswerData(); - - - - }); - }); - prepAnswerData(); - - if ($(".post-form").length > 0) { - $(".post-form").get(0).addEventListener("submit", function (e) { - //if ($(".not-marked").length > 0 && e.submitter.name != "skip") { - // e.preventDefault(); // before the code - // alert("Ensure all answers are marked first"); - //} - - }); - } - - let dicom_images = document.getElementsByClassName("dicom-image"); - if (dicom_images.length) { - - for (let element of dicom_images) { - setUpDicom(element) - } - - } - - - loadDicomViewer(); - - if ($("#question-mark-list").length) { - $(".show-all-button").click(() => { - $("#question-mark-list li").show(); - }); - $(".show-unmarked-button").click(() => { - $("#question-mark-list li").each((n, el) => { - // Can't seem to get django to output this as a int.... - if (parseInt(el.dataset.markcount) < 1) { - $(el).hide(); - } - }) - }); - - } - -// $(document).keydown(keyDownHandler); -// $(document).keyup(keyUpHandler); - - $("#button-select-add-exam").click(function (evt) { - // Find selected objects - - - - var jqxhr = $.get(evt.target.dataset.exam_list_url, function (data) { - console.log(data); - $("#exam-options").empty(); - data.forEach((obj, n) => { - $("#exam-options").append($(document.createElement('button')).prop({ - type: 'button', - innerHTML: obj.name, - class: '', - - }).data("exam-id", obj.id).click((evt) => { - addToExam(evt) - })) - }) - }) - .done(function () { - //alert( "second success" ); - }) - .fail(function () { - //alert( "error" ); - }) - .always(function () { - //alert( "finished" ); - }); - - return; - - - function addToExam(evt) { - - let ids = [] - $("tbody input:checked").each((n, el) => { - ids.push(el.value) - }) - - // if no question selected - if (ids.length < 1) { - toastr.info('No question selected.'); - return - } - let post_url = document.getElementById("button-select-add-exam").dataset.exam_json_edit_url; - let type = document.getElementById("button-select-add-exam").dataset.type; - let csrf = document.getElementById("button-select-add-exam").dataset.csrf; - $.ajax({ - url: post_url, - data: { - csrfmiddlewaretoken: csrf, - add_exam_questions: JSON.stringify(ids), - type: type, - exam_id: $(evt.target).data("exam-id"), - }, - type: "POST", - dataType: "json", - }) - // $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly - .done(function (data) { - console.log(data); - - if (data.status == "success") { - toastr.info('Questions added to exams.') - } - }) - .always(function () { - console.log('[Done]'); - $("#exam-options").empty(); - }) - } - }); - - -}); - -window.loadDicomViewerEvent = new Event('loadDicomViewer'); -window.addEventListener('loadDicomViewer', function (e) { - //console.log("listen", e.detail) - let images = []; - //console.log($("#image_form_set img")); - $(e.detail).each((n, el) => { - images.push($(el).attr("src")); - }); - //console.log("listen2", images) - loadDicomViewer(images); -}, false); - -window.loadDicomViewerEvent = new Event('loadDicomViewerUrls'); -window.addEventListener('loadDicomViewerUrls', function (e) { - - loadDicomViewer(e.detail.images, e.detail.annotations); -}, false); - -function loadDicomViewer(images_to_load, annotations_to_load) { - console.log("loadDicomViewer", images_to_load); - let single_dicom = document.getElementById("single-dicom-viewer"); - if (single_dicom) { - $(single_dicom).empty() - let images = single_dicom.dataset.images; - - if (images_to_load != undefined) { - images = images_to_load; - //} else if (images.indexOf(",") > 0) { - } else if (images == undefined || images == "") { - // No images to load - return - } else { - images = JSON.parse(images); - } - - console.log(images); - - let annotations = single_dicom.dataset.annotations; - if (annotations_to_load != undefined) { - annotations = annotations_to_load; - } else if (annotations && annotations.indexOf(",") > 0) { - annotations = JSON.parse(annotations); - } else { - annotations = undefined; - } - - let load_as_stack; - if (images.length > 5) { - load_as_stack = true; - } else { - load_as_stack = false; - } - - if (images) { - dicomViewer.loadCornerstone($(single_dicom), null, images, annotations, load_as_stack); - } - } -} - -function prepAnswerData() { - //$("#id_correct").val($("li.correct").map(function() { - // ans = $(this).text(); - // window.marked_answers["correct"].push(ans); - // return ans - //}).get().join('--//--')); - //$("#id_half_correct").val($("li.half-correct").map(function() { - // ans = $(this).text(); - // window.marked_answers["half-correct"].push(ans); - // return ans; - //}).get().join('--//--')); - //$("#id_incorrect").val($("li.incorrect").map(function() { - // ans = $(this).text(); - // window.marked_answers["incorrect"].push(ans); - // return ans; - //}).get().join('--//--')); - - window.marked_answers["correct"] = []; - window.marked_answers["half-correct"] = []; - window.marked_answers["incorrect"] = []; - $("li span.correct").map(function () { - let ans = $(this).text(); - window.marked_answers["correct"].push(ans); - }) - $("li span.half-correct").map(function () { - let ans = $(this).text(); - window.marked_answers["half-correct"].push(ans); - }) - $("li span.incorrect").map(function () { - let ans = $(this).text(); - window.marked_answers["incorrect"].push(ans); - }) - $("#id_marked_answers").val(JSON.stringify(window.marked_answers)); -} - -function loadJsonToolStateOnCurrentImage(json) { - let el = document.getElementById("dicom-image"); - - const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager; - - const c = cornerstone.getEnabledElement(el); - - let image_id = c.image.imageId; - - let tool_state_no_id = JSON.parse(json); - - let tool_state = {}; - tool_state[image_id] = tool_state_no_id; - - toolStateManager.restoreToolState(tool_state); - - cornerstone.reset(el); - - return tool_state; - - -} - -function setUpDicom(element) { - - console.log("Dicom element found ", $(element)); - - $(element).bind('contextmenu', function (e) { - return false; - }); - - $(element).dblclick((evt) => { - element.requestFullscreen(); - }) - - cornerstoneBase64ImageLoader.external.cornerstone = cornerstone; - cornerstoneWebImageLoader.external.cornerstone = cornerstone; - cornerstoneWADOImageLoader.external.cornerstone = cornerstone; - - cornerstoneTools.init(); - const PanTool = cornerstoneTools.PanTool; - const ZoomTool = cornerstoneTools.ZoomTool; - const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool; - const WwwcTool = cornerstoneTools.WwwcTool; - const WwwcRegionTool = cornerstoneTools.WwwcRegionTool; - const RotateTool = cornerstoneTools.RotateTool; - const StackScrollTool = cornerstoneTools.StackScrollTool; - const MagnifyTool = cornerstoneTools.MagnifyTool; - const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool; - - - const imageIds = element.dataset.url.split(","); - - console.log("Dicom - load imageId: ", imageIds); - for (let index = 0; index < imageIds.length; index++) { - if (imageIds[index].endsWith("dcm")) { - imageIds[index] = "wadouri:" + imageIds[index]; - } - - } - - cornerstone.enable(element); - cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) { - cornerstone.displayImage(element, image); - - cornerstoneTools.addToolForElement(element, PanTool); - cornerstoneTools.addToolForElement(element, ZoomTool); - cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool); - cornerstoneTools.addToolForElement(element, WwwcTool); - cornerstoneTools.addToolForElement(element, WwwcRegionTool); - cornerstoneTools.addToolForElement(element, RotateTool); - cornerstoneTools.addToolForElement(element, StackScrollTool); - cornerstoneTools.addToolForElement(element, MagnifyTool); - - cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, { - configuration: { - getTextCallback: () => {}, - changeTextCallback: () => {}, - allowEmptyLabel: true, - renderDashed: false, - drawHandles: false, - drawHandlesOnHover: true, - }, - }); - - - - // Enable our tools - // Avoid incorrect aspect ratio - cornerstoneTools.setToolActiveForElement(element, "Pan", { - mouseButtonMask: 1 - }); - cornerstoneTools.setToolActiveForElement(element, "Wwwc", { - mouseButtonMask: 2 - }); - cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", { - mouseButtonMask: 3 - }); - cornerstoneTools.setToolActiveForElement(element, "Zoom", { - mouseButtonMask: 4 - }); - - if (element.dataset.edit_annotation == "true") { - cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", { - mouseButtonMask: 2 - }); - } else { - cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate"); - } - - if (element.dataset.annotations) { - loadJsonToolStateOnCurrentImage(element.dataset.annotations) - } - - cornerstone.resize(element); - - - }); - -} - -//function keyUpHandler(e) { -// if (e.key == "Control") { -// dicomViewer.registerPrimaryDicomInterface(e); -// window.control_pressed = false; -// } -//} -// -//function keyDownHandler(e) { -// // Ignore our custom keybindings if we are currently in a field that -// // accepts some kind of input -// if ($("*:focus:not(disabled)").is("textarea, input")) { -// // unless a modifier key is pressed (not shift) -// if (e.altKey ? true : false || e.ctrlKey ? true : false) {} else { -// return; -// } -// } -// -// if (e.key == "Control") { -// if (window.control_pressed == false) { -// window.control_pressed = true; -// dicomViewer.registerAltDicomInterface(e); -// } -// } -// -// var charCode = typeof e.which == "number" ? e.which : e.keyCode; -// console.log(e, charCode); -// -// function numberKeyPressed(e, x) { -// if (e.altKey ? true : false) { -// dicomViewer.selectThumb(x); -// e.preventDefault(); -// } else {} -// } -// -// switch (charCode) { -// case 13: // Return -// if (e.shiftKey ? true : false) { -// //$(".next-button:last").click(); -// } else { -// //$(".check-button:last").click(); -// } -// break; -// case 32: // Space -// if (e.shiftKey ? true : false) { -// //$(".previous-button:last").click(); -// } else { -// //$(".next-button:last").click(); -// } -// e.preventDefault(); // Needed to stop the default action (scroll) -// break; -// case 46: // . -// //toggleFlagged(); -// break; -// -// // Numbers 1-9 select the corresponding answer (if it exists) -// // TODO: fix for multi question questions -// case 49: // 1 -// numberKeyPressed(e, 0); -// break; -// case 50: // 2 -// numberKeyPressed(e, 1); -// break; -// case 51: // 3 -// numberKeyPressed(e, 2); -// break; -// case 52: // 4 -// numberKeyPressed(e, 3); -// break; -// case 53: // 5 -// numberKeyPressed(e, 4); -// break; -// case 54: // 6 -// numberKeyPressed(e, 5); -// break; -// case 55: // 7 -// numberKeyPressed(e, 6); -// break; -// case 56: // 8 -// numberKeyPressed(e, 7); -// break; -// case 57: // 9 -// numberKeyPressed(e, 8); -// break; -// case 72: // H -// previousQuestion(); -// break; -// case 76: // L -// //nextQuestion(); -// break; -// -// case 102: // f -// //$("#filter-toggle").click(); -// break; -// case 103: // g -// //$("#options").slideDown("slow"); -// //$("#goto-question-input").focus(); -// //e.preventDefault(); -// break; -// -// // Vim like scrolling (incredibly important) -// case 106: // j -// //window.scrollBy(0, 25); -// break; -// case 107: // k -// //window.scrollBy(0, -25); -// break; -// } -//} - - -function create_popup_window(url, title) { - newwindow = window.open(url, title, 'height=800,width=600'); - if (window.focus) { - newwindow.focus() - } - return false; -} - -window.create_popup_window = create_popup_window - -function delete_multiple(url, csrf_token) { - - let answer_ids = []; - $("tbody input:checked").each((n, el) => { - answer_ids.push(el.value); - }) - if (confirm(`Delete ${answer_ids.length} answers?`)) { - $.ajax({ - url: url, - data: { - csrfmiddlewaretoken: csrf_token, - answer_ids: JSON.stringify(answer_ids) // true if checked else false - }, - type: "POST", - dataType: "json", - }) - // $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly - .done(function (data) { - console.log(data); - - if (data.status == "success") { - toastr.info('Deleted.') - } - // show some message according to the response. - // For eg. A message box showing that the status has been changed - }) - .always(function () { - console.log('[Done]'); - }) - } - -} +import * as dicomViewer from "./dicomViewer.js" + +window.dicomViewer = dicomViewer; + +window.marked_answers = { + "correct": [], + "half-correct": [], + "incorrect": [], +} + +window.control_pressed = false; + + +$(document).ready(function () { + $("table thead th input").click((e) => { + let status = e.currentTarget.checked; + $("table tbody input").prop("checked", status); + }) + + //$(".answer-list li").each(function (index, element) { + // $(element).append($("G")); + //}); + + $(".answer-list span.answer").each(function (index, element) { + console.log(element); + + $(element).click(function (e) { + + var classes = ['answer correct', 'answer half-correct', 'answer incorrect']; + $(element).each(function () { + let mark = classes[($.inArray(this.className, classes) + 1) % classes.length]; + this.className = mark + this.dataset.newmark = mark.split(" ")[1] + }); + + prepAnswerData(); + + + + }); + }); + prepAnswerData(); + + if ($(".post-form").length > 0) { + $(".post-form").get(0).addEventListener("submit", function (e) { + //if ($(".not-marked").length > 0 && e.submitter.name != "skip") { + // e.preventDefault(); // before the code + // alert("Ensure all answers are marked first"); + //} + + }); + } + + let dicom_images = document.getElementsByClassName("dicom-image"); + if (dicom_images.length) { + + for (let element of dicom_images) { + setUpDicom(element) + } + + } + + + loadDicomViewer(); + + if ($("#question-mark-list").length) { + $(".show-all-button").click(() => { + $("#question-mark-list li").show(); + }); + $(".show-unmarked-button").click(() => { + $("#question-mark-list li").each((n, el) => { + // Can't seem to get django to output this as a int.... + if (parseInt(el.dataset.markcount) < 1) { + $(el).hide(); + } + }) + }); + + } + +// $(document).keydown(keyDownHandler); +// $(document).keyup(keyUpHandler); + + $("#button-select-add-exam").click(function (evt) { + // Find selected objects + + + + var jqxhr = $.get(evt.target.dataset.exam_list_url, function (data) { + console.log(data); + $("#exam-options").empty(); + data.forEach((obj, n) => { + $("#exam-options").append($(document.createElement('button')).prop({ + type: 'button', + innerHTML: obj.name, + class: '', + + }).data("exam-id", obj.id).click((evt) => { + addToExam(evt) + })) + }) + }) + .done(function () { + //alert( "second success" ); + }) + .fail(function () { + //alert( "error" ); + }) + .always(function () { + //alert( "finished" ); + }); + + return; + + + function addToExam(evt) { + + let ids = [] + $("tbody input:checked").each((n, el) => { + ids.push(el.value) + }) + + // if no question selected + if (ids.length < 1) { + toastr.info('No question selected.'); + return + } + let post_url = document.getElementById("button-select-add-exam").dataset.exam_json_edit_url; + let type = document.getElementById("button-select-add-exam").dataset.type; + let csrf = document.getElementById("button-select-add-exam").dataset.csrf; + $.ajax({ + url: post_url, + data: { + csrfmiddlewaretoken: csrf, + add_exam_questions: JSON.stringify(ids), + type: type, + exam_id: $(evt.target).data("exam-id"), + }, + type: "POST", + dataType: "json", + }) + // $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly + .done(function (data) { + console.log(data); + + if (data.status == "success") { + toastr.info('Questions added to exams.') + } + }) + .always(function () { + console.log('[Done]'); + $("#exam-options").empty(); + }) + } + }); + + +}); + +window.loadDicomViewerEvent = new Event('loadDicomViewer'); +window.addEventListener('loadDicomViewer', function (e) { + //console.log("listen", e.detail) + let images = []; + //console.log($("#image_form_set img")); + $(e.detail).each((n, el) => { + images.push($(el).attr("src")); + }); + //console.log("listen2", images) + loadDicomViewer(images); +}, false); + +window.loadDicomViewerEvent = new Event('loadDicomViewerUrls'); +window.addEventListener('loadDicomViewerUrls', function (e) { + + loadDicomViewer(e.detail.images, e.detail.annotations); +}, false); + +function loadDicomViewer(images_to_load, annotations_to_load) { + console.log("loadDicomViewer", images_to_load); + let single_dicom = document.getElementById("single-dicom-viewer"); + if (single_dicom) { + $(single_dicom).empty() + let images = single_dicom.dataset.images; + + if (images_to_load != undefined) { + images = images_to_load; + //} else if (images.indexOf(",") > 0) { + } else if (images == undefined || images == "") { + // No images to load + return + } else { + images = JSON.parse(images); + } + + console.log(images); + + let annotations = single_dicom.dataset.annotations; + if (annotations_to_load != undefined) { + annotations = annotations_to_load; + } else if (annotations && annotations.indexOf(",") > 0) { + annotations = JSON.parse(annotations); + } else { + annotations = undefined; + } + + let load_as_stack; + if (images.length > 5) { + load_as_stack = true; + } else { + load_as_stack = false; + } + + if (images) { + dicomViewer.loadCornerstone($(single_dicom), null, images, annotations, load_as_stack); + } + } +} +window.loadDicomViewer = loadDicomViewer + +function prepAnswerData() { + //$("#id_correct").val($("li.correct").map(function() { + // ans = $(this).text(); + // window.marked_answers["correct"].push(ans); + // return ans + //}).get().join('--//--')); + //$("#id_half_correct").val($("li.half-correct").map(function() { + // ans = $(this).text(); + // window.marked_answers["half-correct"].push(ans); + // return ans; + //}).get().join('--//--')); + //$("#id_incorrect").val($("li.incorrect").map(function() { + // ans = $(this).text(); + // window.marked_answers["incorrect"].push(ans); + // return ans; + //}).get().join('--//--')); + + window.marked_answers["correct"] = []; + window.marked_answers["half-correct"] = []; + window.marked_answers["incorrect"] = []; + $("li span.correct").map(function () { + let ans = $(this).text(); + window.marked_answers["correct"].push(ans); + }) + $("li span.half-correct").map(function () { + let ans = $(this).text(); + window.marked_answers["half-correct"].push(ans); + }) + $("li span.incorrect").map(function () { + let ans = $(this).text(); + window.marked_answers["incorrect"].push(ans); + }) + $("#id_marked_answers").val(JSON.stringify(window.marked_answers)); +} + +function loadJsonToolStateOnCurrentImage(json) { + let el = document.getElementById("dicom-image"); + + const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager; + + const c = cornerstone.getEnabledElement(el); + + let image_id = c.image.imageId; + + let tool_state_no_id = JSON.parse(json); + + let tool_state = {}; + tool_state[image_id] = tool_state_no_id; + + toolStateManager.restoreToolState(tool_state); + + cornerstone.reset(el); + + return tool_state; + + +} + +function setUpDicom(element) { + + console.log("Dicom element found ", $(element)); + + $(element).bind('contextmenu', function (e) { + return false; + }); + + $(element).dblclick((evt) => { + element.requestFullscreen(); + }) + + cornerstoneBase64ImageLoader.external.cornerstone = cornerstone; + cornerstoneWebImageLoader.external.cornerstone = cornerstone; + cornerstoneWADOImageLoader.external.cornerstone = cornerstone; + + cornerstoneTools.init(); + const PanTool = cornerstoneTools.PanTool; + const ZoomTool = cornerstoneTools.ZoomTool; + const ZoomMouseWheelTool = cornerstoneTools.ZoomMouseWheelTool; + const WwwcTool = cornerstoneTools.WwwcTool; + const WwwcRegionTool = cornerstoneTools.WwwcRegionTool; + const RotateTool = cornerstoneTools.RotateTool; + const StackScrollTool = cornerstoneTools.StackScrollTool; + const MagnifyTool = cornerstoneTools.MagnifyTool; + const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool; + + + const imageIds = element.dataset.url.split(","); + + console.log("Dicom - load imageId: ", imageIds); + for (let index = 0; index < imageIds.length; index++) { + if (imageIds[index].endsWith("dcm")) { + imageIds[index] = "wadouri:" + imageIds[index]; + } + + } + + cornerstone.enable(element); + cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) { + cornerstone.displayImage(element, image); + + cornerstoneTools.addToolForElement(element, PanTool); + cornerstoneTools.addToolForElement(element, ZoomTool); + cornerstoneTools.addToolForElement(element, ZoomMouseWheelTool); + cornerstoneTools.addToolForElement(element, WwwcTool); + cornerstoneTools.addToolForElement(element, WwwcRegionTool); + cornerstoneTools.addToolForElement(element, RotateTool); + cornerstoneTools.addToolForElement(element, StackScrollTool); + cornerstoneTools.addToolForElement(element, MagnifyTool); + + cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, { + configuration: { + getTextCallback: () => {}, + changeTextCallback: () => {}, + allowEmptyLabel: true, + renderDashed: false, + drawHandles: false, + drawHandlesOnHover: true, + }, + }); + + + + // Enable our tools + // Avoid incorrect aspect ratio + cornerstoneTools.setToolActiveForElement(element, "Pan", { + mouseButtonMask: 1 + }); + cornerstoneTools.setToolActiveForElement(element, "Wwwc", { + mouseButtonMask: 2 + }); + cornerstoneTools.setToolActiveForElement(element, "ZoomMouseWheel", { + mouseButtonMask: 3 + }); + cornerstoneTools.setToolActiveForElement(element, "Zoom", { + mouseButtonMask: 4 + }); + + if (element.dataset.edit_annotation == "true") { + cornerstoneTools.setToolActiveForElement(element, "ArrowAnnotate", { + mouseButtonMask: 2 + }); + } else { + cornerstoneTools.setToolEnabledForElement(element, "ArrowAnnotate"); + } + + if (element.dataset.annotations) { + loadJsonToolStateOnCurrentImage(element.dataset.annotations) + } + + cornerstone.resize(element); + + + }); + +} + +//function keyUpHandler(e) { +// if (e.key == "Control") { +// dicomViewer.registerPrimaryDicomInterface(e); +// window.control_pressed = false; +// } +//} +// +//function keyDownHandler(e) { +// // Ignore our custom keybindings if we are currently in a field that +// // accepts some kind of input +// if ($("*:focus:not(disabled)").is("textarea, input")) { +// // unless a modifier key is pressed (not shift) +// if (e.altKey ? true : false || e.ctrlKey ? true : false) {} else { +// return; +// } +// } +// +// if (e.key == "Control") { +// if (window.control_pressed == false) { +// window.control_pressed = true; +// dicomViewer.registerAltDicomInterface(e); +// } +// } +// +// var charCode = typeof e.which == "number" ? e.which : e.keyCode; +// console.log(e, charCode); +// +// function numberKeyPressed(e, x) { +// if (e.altKey ? true : false) { +// dicomViewer.selectThumb(x); +// e.preventDefault(); +// } else {} +// } +// +// switch (charCode) { +// case 13: // Return +// if (e.shiftKey ? true : false) { +// //$(".next-button:last").click(); +// } else { +// //$(".check-button:last").click(); +// } +// break; +// case 32: // Space +// if (e.shiftKey ? true : false) { +// //$(".previous-button:last").click(); +// } else { +// //$(".next-button:last").click(); +// } +// e.preventDefault(); // Needed to stop the default action (scroll) +// break; +// case 46: // . +// //toggleFlagged(); +// break; +// +// // Numbers 1-9 select the corresponding answer (if it exists) +// // TODO: fix for multi question questions +// case 49: // 1 +// numberKeyPressed(e, 0); +// break; +// case 50: // 2 +// numberKeyPressed(e, 1); +// break; +// case 51: // 3 +// numberKeyPressed(e, 2); +// break; +// case 52: // 4 +// numberKeyPressed(e, 3); +// break; +// case 53: // 5 +// numberKeyPressed(e, 4); +// break; +// case 54: // 6 +// numberKeyPressed(e, 5); +// break; +// case 55: // 7 +// numberKeyPressed(e, 6); +// break; +// case 56: // 8 +// numberKeyPressed(e, 7); +// break; +// case 57: // 9 +// numberKeyPressed(e, 8); +// break; +// case 72: // H +// previousQuestion(); +// break; +// case 76: // L +// //nextQuestion(); +// break; +// +// case 102: // f +// //$("#filter-toggle").click(); +// break; +// case 103: // g +// //$("#options").slideDown("slow"); +// //$("#goto-question-input").focus(); +// //e.preventDefault(); +// break; +// +// // Vim like scrolling (incredibly important) +// case 106: // j +// //window.scrollBy(0, 25); +// break; +// case 107: // k +// //window.scrollBy(0, -25); +// break; +// } +//} + + +function create_popup_window(url, title) { + newwindow = window.open(url, title, 'height=800,width=600'); + if (window.focus) { + newwindow.focus() + } + return false; +} + +window.create_popup_window = create_popup_window + +function delete_multiple(url, csrf_token) { + + let answer_ids = []; + $("tbody input:checked").each((n, el) => { + answer_ids.push(el.value); + }) + if (confirm(`Delete ${answer_ids.length} answers?`)) { + $.ajax({ + url: url, + data: { + csrfmiddlewaretoken: csrf_token, + answer_ids: JSON.stringify(answer_ids) // true if checked else false + }, + type: "POST", + dataType: "json", + }) + // $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly + .done(function (data) { + console.log(data); + + if (data.status == "success") { + toastr.info('Deleted.') + } + // show some message according to the response. + // For eg. A message box showing that the status has been changed + }) + .always(function () { + console.log('[Done]'); + }) + } + +} window.delete_multiple = delete_multiple \ No newline at end of file diff --git a/anatomy/templates/anatomy/mark_overview.html b/anatomy/templates/anatomy/mark_overview.html index 9fdddcdd..54cadabb 100644 --- a/anatomy/templates/anatomy/mark_overview.html +++ b/anatomy/templates/anatomy/mark_overview.html @@ -1,20 +1,20 @@ -{% extends 'anatomy/exams.html' %} - -{% block content %} -
-

Marking exam: {{ exam.name }}

- You can start marking from a particular question by clicking on it below. - -
- -
- - - -
+{% extends 'anatomy/exams.html' %} + +{% block content %} +
+

Marking exam: {{ exam.name }}

+ You can start marking from a particular question by clicking on it below. + +
+ +
+ + + +
{% endblock %} \ No newline at end of file diff --git a/anatomy/views.py b/anatomy/views.py index 3891b746..20a0841b 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -1,1111 +1,1111 @@ -from django.forms.models import model_to_dict -from django.shortcuts import render, get_object_or_404, redirect -from django.views.decorators.csrf import csrf_exempt -from django import forms - -# from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required, user_passes_test -from django.contrib.auth.models import User -from django.core.exceptions import FieldError, PermissionDenied - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic.detail import DetailView - -from django.views.generic.edit import CreateView, UpdateView, DeleteView -from django.views.generic import ListView - -from django.db.models.functions import Lower - -from django.core.cache import cache - -from django.urls import reverse_lazy, reverse - -from django.http import Http404, JsonResponse - -from django.http import HttpResponseRedirect, HttpResponse - -from dal import autocomplete - -from .forms import ( - AnatomyAnswerForm, - AnswerFormSet, - AnswerUpdateFormSet, - BodyPartForm, - ExaminationForm, - MarkAnatomyQuestionForm, - AnatomyQuestionForm, - StructureForm, - ExamForm, -) -from .models import ( - AnatomyQuestion, - BodyPart, - CidUserAnswer, - # Examination, - Structure, - Exam, - Answer, - # HalfMarkAnswers, - # IncorrectAnswers, -) -from generic.models import CidUser, Examination -from generic.views import ExamCloneMixin, ExamViews, GenericViewBase -from reversion.views import RevisionMixin - -from .decorators import user_is_author_or_anatomy_checker - -from .tables import AnatomyQuestionTable, AnatomyUserAnswerTable -from .filters import AnatomyQuestionFilter, AnatomyUserAnswerFilter - -from django_tables2 import SingleTableView, SingleTableMixin -from django_filters.views import FilterView - -from collections import defaultdict -import os -import json -import statistics -import plotly.express as px - -from helpers.images import image_as_base64 - -from django.template.defaulttags import register - -from generic.mixins import SuperuserRequiredMixin - -from rest_framework import viewsets, permissions - -from .serializers import ExamSerializer - - -class AuthorOrCheckerRequiredMixin(object): - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user.groups.filter(name="anatomy_checker").exists(): - return obj - if self.request.user not in obj.author.all(): - raise PermissionDenied() # or Http404 - return obj - - -@register.filter -def get_item(dictionary, key): - if dictionary is None: - return None - return dictionary.get(key) - - -def user_is_admin(user): - if user: - if user.pk == 1: - return True - return False - - -def question_list(request): - questions = AnatomyQuestion.objects.all() - - if not request.user.groups.filter(name="anatomy_checker").exists(): - questions = questions.filter(open_access=False) - # raise PermissionDenied() - - return render(request, "anatomy/question_list.html", {"questions": questions}) - - -@login_required -def answer_question(request, pk): - question = get_object_or_404(AnatomyQuestion, pk=pk) - answer = question.user_answers.filter( - user=request.user - ).first() # .filter(user=User) - if request.method == "POST": - if answer: - form = AnatomyAnswerForm(request.POST, instance=answer) - else: - form = AnatomyAnswerForm(request.POST) - if form.is_valid(): - answer = form.save(commit=False) - answer.user = request.user - answer.question = question - # answer.published_date = timezone.now() - answer.save() - return redirect("question_detail", pk=pk) - else: - form = AnatomyAnswerForm(instance=answer) - return render( - request, "anatomy/answer_question.html", {"form": form, "question": question} - ) - - -# @login_required -# def exam_take(request, pk, sk): -# """ -# Allows taking of the exam on the django server (when logged in) -# -# No longer used (deprecated in favour of using RTS) -# """ -# exam = get_object_or_404(Exam, pk=pk) -# -# questions = exam.exam_questions.all() -# -# try: -# question = questions[sk] -# except IndexError: -# raise Http404("Exam question does not exist") -# -# n = sk -# -# question_details = { -# "total": len(questions), -# "current": n + 1, -# } -# -# # Get data for flagged -# # answered_questions = UserAnswer.objects.filter(user=request.user).values_list("question__pk", flat=True) -# user_answer_data = UserAnswer.objects.filter(user=request.user).values_list( -# "question__pk", "answer", "flagged" -# ) -# # flagged_questions = UserAnswer.objects.filter(user=request.user, flagged=True).values_list("question__pk", flat=True) -# # flagged_questions I= UserAnswer.filter(user=request.user, ).values_list("question__pk", flat=True) -# -# answered_questions = set() -# flagged_questions = set() -# for ans_pk, answer, flagged in user_answer_data: -# if len(answer.strip()) > 0: -# answered_questions.add(ans_pk) -# if flagged: -# flagged_questions.add(ans_pk) -# -# # u = question.user_answers -# answer = question.user_answers.filter( -# user=request.user -# ).first() # .filter(user=User) -# if request.method == "POST": -# if answer: -# form = AnatomyAnswerForm(request.POST, instance=answer) -# else: -# form = AnatomyAnswerForm(request.POST) -# if form.is_valid(): -# answer = form.save(commit=False) -# answer.user = request.user -# answer.question = question -# # answer.published_date = timezone.now() -# answer.save() -# if "next" in request.POST: -# return redirect("anatomy:exam_take", pk=pk, sk=n + 1) -# elif "previous" in request.POST: -# return redirect("anatomy:exam_take", pk=pk, sk=n - 1) -# else: -# form = AnatomyAnswerForm(instance=answer) -# return render( -# request, -# "anatomy/exam.html", -# { -# "exam": exam, -# "form": form, -# "question": question, -# "question_details": question_details, -# "questions": questions, -# "flagged_questions": flagged_questions, -# "answered_questions": answered_questions, -# "answer": answer, -# }, -# ) - - -# def flag_question(request): -# pk = request.GET.get("pk", None) -# ans = UserAnswer.objects.filter(user=request.user, question__pk=pk).first() -# ans.flagged = not ans.flagged -# ans.save() -# data = {"flagged": ans.flagged} -# return JsonResponse(data) - - -def loadJsonAnswer(answer): - # Backend eid is just the object pk number - # in the frontend this is appended with the type - # e.g. anatomy/1 - - # TODO Update to only allow valid Candidates - eid = int(answer["eid"].split("/")[1]) - cid = int(answer["cid"]) - # As access is not restricted make sure the data appears valid - if (not isinstance(cid, int)) or ( - not isinstance(eid, int) or (not isinstance(answer["ans"], str)) - ): - return False, JsonResponse( - {"success": False, "error": "cid or eid or answers not defined"} - ) - - # The model should catch invalid data but this should be less intensive - max_int = 999999999999999999999 - if cid > max_int or eid > max_int: - return False, JsonResponse({"success": False, "error": "invalid cid"}) - - exam = get_object_or_404(Exam, pk=eid) - - - if not exam.check_cid_user(cid, answer["passcode"]): - return False, JsonResponse( - {"success": False, "error": "invalid cid / passcode"} - ) - - # if not exam.active: - # return False, JsonResponse( - # {"success": False, "error": "No active exam: {}".format(eid)} - # ) - - exiting_answers = CidUserAnswer.objects.filter( - question__id=answer["qid"], exam__id=eid, cid=cid - ) - - if not exiting_answers: - ans = CidUserAnswer(answer=answer["ans"], cid=cid) - ans.question_id = answer["qid"] - ans.exam_id = eid - ans.score = Answer.MarkOptions.UNMARKED - - ans.full_clean() - - ans.save() - else: - # Update an existing answer - # should never be more than one (famous last words) - ans = exiting_answers[0] - ans.answer = answer["ans"] - ans.score = Answer.MarkOptions.UNMARKED - ans.full_clean() - ans.save() - - return True, None - - -# @csrf_exempt -# def postExamAnswers(request): -# if request.is_ajax and request.method == "POST": -# -# n = 0 -# # horrible but it works -# #for k in request.POST.dict(): -# -# for answer in json.loads(request.POST.get("answers")): -# ret = loadJsonAnswer(answer) -# -# if ret is not True: -# return ret -# n = n + 1 -# -# # print(UserAnswer.objects.filter(exam__id=q["eid"])) -# # print(request.urlencode()) -# -# return JsonResponse({"success": True, "question_count": n}) -# return JsonResponse({"success": False, "error": "Invalid data"}) -# -# # return render(request, "anatomy/exam.html", {"exam" : exam, "question" : question}) - - -@login_required -def mark_all(request, exam_pk, sk): - return mark(request, exam_pk, sk, unmarked_exam_answers_only=False) - - -@login_required -def mark_review(request, exam_pk, sk): - return mark(request, exam_pk, sk, unmarked_exam_answers_only=False, review=True) - - -# @user_passes_test(user_is_admin, login_url="/accounts/login") -@login_required -def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): - exam = get_object_or_404(Exam, pk=exam_pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - mark_url = "anatomy:mark" - if review: - mark_url = "anatomy:mark_review" - elif not unmarked_exam_answers_only: - mark_url = "anatomy:mark_all" - - questions = exam.exam_questions.all() - - n = sk - - question_details = { - "total": len(questions), - "current": n + 1, - } - - try: - question = questions[sk] - except IndexError: - raise Http404("Exam question does not exist") - - if request.method == "POST": - - form = MarkAnatomyQuestionForm(request.POST) - # ******************************* - # TODO: convert to JSON request - # ******************************* - if form.is_valid(): - # If skip button is pressed skip the question without marking - if "skip" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) - - cd = form.cleaned_data - - marked_answers = json.loads(cd.get("marked_answers")) - - # This should probably use json (or something else...) - # ajax..... - to_run = ( - ("correct", Answer.MarkOptions.CORRECT), - ("half-correct", Answer.MarkOptions.HALF_MARK), - ("incorrect", Answer.MarkOptions.INCORRECT), - ) - - for status_string, status in to_run: - for ans in marked_answers[status_string]: - ans = ans.strip() - if ans == "": - continue - - a = Answer.objects.filter( - answer__iexact=ans, question_id=question.pk - ).first() - - if a is None: - a = Answer() - a.question_id = question.pk - a.answer = ans - - a.status = status - a.save() - - if "next" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) - elif "previous" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n - 1) - - # Reset user answers (relies on the functional javascript) - unmarked_user_answers = set() - else: - form = MarkAnatomyQuestionForm() - - # correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT) - # half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK) - # incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT) - - correct_answers = [] - half_mark_answers = [] - incorrect_answers = [] - review_user_answers = [] - unmarked_answers_bool = False - unmarked_user_answers = [] - - if review: - unmarked_user_answers = question.get_user_answers( - exam_pk=exam_pk, include_normal=False - ) - elif unmarked_exam_answers_only: - unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk) - else: - unmarked_user_answers = question.get_unmarked_user_answers() - - if not unmarked_exam_answers_only: - correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT) - half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK) - incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT) - - if review: - for ans in unmarked_user_answers: - # duplicated from user answer model.... - marked_ans = question.answers.filter(answer_compare__iexact=ans).first() - mark = "unmarked" - if marked_ans is not None: - if marked_ans.status == Answer.MarkOptions.CORRECT: - mark = "correct" - elif marked_ans.status == Answer.MarkOptions.HALF_MARK: - mark = "half-correct" - elif marked_ans.status == Answer.MarkOptions.INCORRECT: - mark = "incorrect" - if mark == "unmarked": - unmarked_answers_bool = True - review_user_answers.append((ans, mark)) - - return render( - request, - "anatomy/mark.html", - { - "exam": exam, - "form": form, - "review": review, - "question": question, - "question_number": n, - "question_details": question_details, - "unmarked_answers_bool": unmarked_answers_bool, - "user_answers": unmarked_user_answers, - "review_user_answers": review_user_answers, - "correct_answers": correct_answers, - "half_mark_answers": half_mark_answers, - "incorrect_answers": incorrect_answers, - "unmarked_exam_answers_only": unmarked_exam_answers_only, - }, - ) - - -@login_required -def exam_scores_refresh(request, pk): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - questions = exam.exam_questions.all() - - cids = CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) - - # Force a score update - for c in cids: - c.get_answer_score(cached=False) - - return render( - request, - "anatomy/base.html", - { - "simple_content": "Answer scores updated", - }, - ) - - -# @login_required -# def exam_scores_cid(request, pk): -# exam = get_object_or_404(Exam, pk=pk) -# -# if not exam.exam_mode: -# raise Http404("Packet not in exam mode") -# -# questions = exam.exam_questions.all() -# -# cids = ( -# CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) -# .order_by("cid") -# .values_list("cid", flat=True) -# .distinct() -# ) -# -# user_answers_and_marks = defaultdict(list) -# user_answers_marks = defaultdict(list) -# user_answers = defaultdict(list) -# user_names = {} -# -# by_question = defaultdict(list) -# unmarked = set() -# -# # Loop through all candidates -# for cid in cids: -# # Convoluted (probably...) -# user_names[cid] = cid -# for q in questions: -# # Get user answer -# s = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() -# if not s: -# # skip if no answer -# user_answers_marks[cid].append(0) -# user_answers[cid].append("") -# by_question[q].append(("", 0)) -# continue -# -# ans = s.answer -# answer_score = s.get_answer_score() -# if answer_score == "unmarked": -# index = exam.get_question_index(q) -# unmarked.add(index) -# user_answers[cid].append(ans) -# user_answers_marks[cid].append(answer_score) -# user_answers_and_marks[cid].append((ans, answer_score)) -# -# by_question[q].append((ans, answer_score)) -# -# user_scores = {} -# for user in user_answers_marks: -# user_scores[user] = sum( -# [i for i in user_answers_marks[user] if i != "unmarked"] -# ) -# -# user_scores_list = list(user_scores.values()) -# -# if len(user_scores_list) < 1: -# mean = 0 -# median = 0 -# mode = 0 -# fig_html = "" -# else: -# mean = statistics.mean(user_scores_list) -# median = statistics.median(user_scores_list) -# try: -# mode = statistics.mode(user_scores_list) -# except statistics.StatisticsError: -# mode = "No unique mode" -# -# df = user_scores_list -# fig = px.histogram( -# df, -# x=0, -# title="{}: distribution of scores".format(exam), -# labels={"0": "Score"}, -# height=400, -# width=600, -# ) -# fig_html = fig.to_html() -# -# max_score = len(questions) * 2 -# -# exam.stats_mean = mean -# exam.stats_median = median -# exam.stats_mode = mode -# -# exam.stats_candidates = len(user_scores_list) -# exam.stats_max_possible = max_score -# -# exam.stats_min = min(user_scores_list) -# exam.stats_max = max(user_scores_list) -# -# exam.stats_graph = fig_html -# exam.save() -# -# return render( -# request, -# "anatomy/exam_scores.html", -# { -# "cids": cids, -# "exam": exam, -# "unmarked": unmarked, -# "questions": questions, -# "by_question": by_question, -# "user_answers": dict(user_answers), -# "user_answers_marks": dict(user_answers_marks), -# "user_scores": user_scores, -# "user_scores_list": user_scores_list, -# "user_names": user_names, -# "user_answers_and_marks": user_answers_and_marks, -# "max_score": max_score, -# "mean": mean, -# "median": median, -# "mode": mode, -# "plot": fig_html, -# }, -# ) - - -def exam_scores_cid_user(request, pk, cid, passcode): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - if not exam.check_cid_user(cid, passcode, request): - raise Http404("Error accessing exam") - - questions = exam.exam_questions.all() - - answers_and_marks = [] - answers_marks = [] - answers = [] - - view_all_results = False - if request.user.groups.filter(name="view_all_results").exists(): - view_all_results = True - - for q in questions: - # Get user answer - user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() - if not user_answer: - # skip if no answer - answers_marks.append(0) - answers.append("") - answer_score = 0 - ans = "Not answered" - else: - ans = user_answer.answer - - answer_score = user_answer.get_answer_score() - - correct_answer = q.get_primary_answer() - - if not exam.publish_results and not view_all_results: - correct_answer = "*****" - answer_score = 0 - answers.append(ans) - answers_marks.append(answer_score) - answers_and_marks.append((ans, answer_score, correct_answer)) - - if "unmarked" in answers_marks: - 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) - else: - total_score = sum(answers_marks) - - max_score = len(questions) * 2 - - return render( - request, - "anatomy/exam_scores_user.html", - { - "exam": exam, - "cid": cid, - "passcode": passcode, - "questions": questions, - "answers": answers, - "answers_marks": answers_marks, - "total_score": total_score, - "max_score": max_score, - "answers_and_marks": answers_and_marks, - "view_all_results": view_all_results, - }, - ) - - -# @login_required -# def exam_scores(request, pk): -# exam = get_object_or_404(Exam, pk=pk) - -# questions = exam.exam_questions.all() - -# users = (CidUserAnswer.objects.filter( -# question__in=questions).values_list("user").distinct()) - -# user_answers_and_marks = defaultdict(list) -# user_answers_marks = defaultdict(list) -# user_answers = defaultdict(list) -# user_names = {} - -# by_question = defaultdict(list) - -# for u in users: -## Convoluted (probably...) -# user_names[u] = User.objects.filter(pk=u[0]).first().get_username() -# for q in questions: -# user_answer = q.user_answers.filter(user__in=u).first() -# if not user_answer: -# user_answers_marks[u].append(0) -# user_answers[u].append("") -# continue - -# ans = user_answer.answer -# answer_score = user_answer.get_answer_score() -# user_answers[u].append(ans) -# user_answers_marks[u].append(answer_score) -# user_answers_and_marks[u].append((ans, answer_score)) - -# by_question[q].append(ans) - -# user_scores = {} -# for user in user_answers_marks: -# user_scores[user] = sum(user_answers_marks[user]) - -# total = len(questions) - -# return render( -# request, -# "anatomy/exam_scores.html", -# { -# "exam": exam, -# "questions": questions, -# "by_question": by_question, -# "user_answers": dict(user_answers), -# "user_answers_marks": dict(user_answers_marks), -# "user_scores": user_scores, -# "user_names": user_names, -# "user_answers_and_marks": user_answers_and_marks, -# }, -# ) - -# def cid_scores(request, pk): -# #exam = get_object_or_404(Exam, pk=pk) -# -# # TODO:Need some kind of test for cid -# cid = pk -# -# #questions = exam.exam_questions.all() -# answers = CidUserAnswer.objects.filter( -# cid=cid) -# -# -# if not answers: -# raise Http404("cid not found") -# -# exam_ids = answers.values_list("exam").distinct() -# -# exams = Exam.objects.filter(id__in=exam_ids) -# -# -# return render( -# request, -# "anatomy/cid_scores.html", -# { -# "exams": exams, -# "cid": cid, -# }, -# ) - - -class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): - model = AnatomyQuestion - form_class = AnatomyQuestionForm - - def get_form_kwargs(self): - kwargs = super(AnatomyQuestionCreateBase, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(AnatomyQuestionCreateBase, self).get_context_data(**kwargs) - if self.request.POST: - context["answer_formset"] = AnswerFormSet( - self.request.POST, self.request.FILES - ) - else: - context["answer_formset"] = AnswerFormSet() - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - formset = context["answer_formset"] - - if formset.is_valid(): - response = super().form_valid(form) - formset.instance = self.object - formset.save() - # If the normal submit button is pressed we save as normal - if "submit" in self.request.POST: - return response - # else we redirect to the clone url - else: - return redirect("anatomy:question_clone", pk=self.object.pk) - - else: - return super().form_invalid(form) - - -class AnatomyQuestionCreate(AnatomyQuestionCreateBase): - - # initial = {'laterality': AnatomyQuestion.NONE} - - def get_initial(self): - if "pk" in self.kwargs: - initial = super().get_initial() - exam = get_object_or_404(Exam, pk=self.kwargs["pk"]) - - initial["exams"] = [exam.id] - - return initial - - -# # There has to be a better way... -# try: -# s = (i.pk for i in self.request.user.AnatomyQuestion_default.site.all()) -# self.initial.update({'site': s}) -# except AttributeError: -# pass -# return self.initial -# -# fields = '__all__' -# #fields = [ 'condition' ] -# #initial = {'date_of_death': '05/01/2018'} -# exclude = [ 'created_date', 'published_date' ] - -# self.object = form.save(commit=False) -# self.object.save() - -# form.instance.author.add(self.request.user.id) -# return super().form_valid(form) - - -# class AnatomyQuestionUpdate(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, -class AnatomyQuestionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView): - model = AnatomyQuestion - form_class = AnatomyQuestionForm - - def get_form_kwargs(self): - kwargs = super(AnatomyQuestionUpdate, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(AnatomyQuestionUpdate, self).get_context_data(**kwargs) - if self.request.POST: - context["answer_formset"] = AnswerUpdateFormSet( - self.request.POST, self.request.FILES, instance=self.object - ) - context["answer_formset"].full_clean() - else: - context["answer_formset"] = AnswerUpdateFormSet(instance=self.object) - return context - - def form_valid(self, form): - - # save exam orders (there must be a better way to do this) - #exam_orders = {} - #for exam in self.object.exams.all(): - # exam_orders[exam] = list(exam.exam_questions.all()) - # print(exam_orders[exam]) - - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - formset = context["answer_formset"] - # logger.debug(formset.is_valid()) - if formset.is_valid(): - response = super().form_valid(form) - formset.instance = self.object - formset.save() - - # restore exam orders - #for exam in self.object.exams.all(): - # if exam in exam_orders and self.object in exam_orders[exam]: - # print(exam_orders[exam]) - # exam.exam_questions.set(exam_orders[exam]) - # exam.save() - - return response - else: - return super().form_invalid(form) - - -class QuestionClone(AnatomyQuestionCreateBase): - """Clones a existing question""" - - def get_initial(self): - # print(self.request) - old_object = get_object_or_404(AnatomyQuestion, pk=self.kwargs["pk"]) - initial_data = model_to_dict(old_object, exclude=["id"]) - - # We don't want to clone the exam details - # exams = old_object.exams.all().values_list("id", flat=True) - - # initial_data["exams"] = list(exams) - - return initial_data - - -@login_required -def create_body_part(request): - form = BodyPartForm(request.POST or None) - if form.is_valid(): - instance = form.save() - return HttpResponse( - '' - % (instance.pk, instance) - ) - return render( - request, "anatomy/create_simple.html", {"form": form, "name": "BodyPart"} - ) - - -@csrf_exempt -def get_body_part_id(request): - if request.is_ajax(): - body_part_name = request.GET["body_part_name"] - body_part_id = BodyPart.objects.get(name=body_part_name).id - data = { - "body_part_id": body_part_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -@login_required -def create_examination(request): - form = ExaminationForm(request.POST or None) - if form.is_valid(): - instance = form.save() - return HttpResponse( - '' - % (instance.pk, instance) - ) - return render( - request, "anatomy/create_simple.html", {"form": form, "name": "Examination"} - ) - - -@csrf_exempt -def get_examination_id(request): - if request.is_ajax(): - examination_name = request.GET["examination_name"] - examination_id = Examination.objects.get(name=examination_name).id - data = { - "examination_id": examination_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -@login_required -def create_structure(request): - form = StructureForm(request.POST or None) - if form.is_valid(): - instance = form.save() - return HttpResponse( - '' - % (instance.pk, instance) - ) - return render( - request, "anatomy/create_simple.html", {"form": form, "name": "Structure"} - ) - - -@csrf_exempt -def get_structure_id(request): - if request.is_ajax(): - structure_name = request.GET["structure_name"] - structure_id = Structure.objects.get(name=structure_name).id - data = { - "structure_id": structure_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = AnatomyQuestion - table_class = AnatomyQuestionTable - template_name = "anatomy/view.html" - - filterset_class = AnatomyQuestionFilter - - # def get_queryset(self): - # qs = super().get_queryset() - - # qs = qs.select_related('modality', 'question_type') - - # return qs - - -@user_is_author_or_anatomy_checker -def question_save_annotation(request, pk): - if request.is_ajax() and request.method == "POST": - question = get_object_or_404(AnatomyQuestion, pk=pk) - - question.image_annotations = request.POST.get("annotation") - print(question.image_annotations) - - question.save() - data = {"status": "success"} - return JsonResponse(data, status=200) - else: - data = {"status": "error"} - return JsonResponse(data, status=400) - - -GenericExamViews = ExamViews( - Exam, AnatomyQuestion, Answer, CidUserAnswer, "anatomy", "anatomy", loadJsonAnswer -) - - -class UserAnswerView(SuperuserRequiredMixin, DetailView): - model = CidUserAnswer - - -class UserAnswerTableView(SuperuserRequiredMixin, SingleTableMixin, FilterView): - model = CidUserAnswer - table_class = AnatomyUserAnswerTable - template_name = "anatomy/user_answer_question_view.html" - - filterset_class = AnatomyUserAnswerFilter - - -class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): - model = CidUserAnswer - template_name = "user_answer_delete.html" - success_url = reverse_lazy("anatomy:user_answer_table_view") - - -class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): - model = AnatomyQuestion - success_url = reverse_lazy("anatomy:question_list") - - -class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - return super().form_valid(form) - - -class ExamUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - return super().form_valid(form) - - -class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): - # queryset = Exam.objects.all().order_by('name') - serializer_class = ExamSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - """ - This view should return a list exams available to a user. - """ - user = self.request.user - if user.groups.filter(name="anatomy_checker").exists(): - return Exam.objects.all() - - return Exam.objects.filter(author__id=user.id) - - -class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Exam - template_name = "exam_confirm_delete.html" - success_url = reverse_lazy("anatomy:index") - - -GenericViews = GenericViewBase("anatomy", AnatomyQuestion, CidUserAnswer, Exam) - - -class ExamClone(ExamCloneMixin, ExamCreate): - """Clone exam view""" - - -class StructureAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - # Don't forget to filter out results depending on the visitor ! - if not self.request.user.is_authenticated: - return Structure.objects.none() - - qs = Structure.objects.all() - - if self.q: - # This raises a fielderror which breaks creating a new item if not caught - try: - qs = qs.filter(name__istartswith=self.q) - except FieldError: - return Structure.objects.none() - - return qs +from django.forms.models import model_to_dict +from django.shortcuts import render, get_object_or_404, redirect +from django.views.decorators.csrf import csrf_exempt +from django import forms + +# from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User +from django.core.exceptions import FieldError, PermissionDenied + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic.detail import DetailView + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView + +from django.db.models.functions import Lower + +from django.core.cache import cache + +from django.urls import reverse_lazy, reverse + +from django.http import Http404, JsonResponse + +from django.http import HttpResponseRedirect, HttpResponse + +from dal import autocomplete + +from .forms import ( + AnatomyAnswerForm, + AnswerFormSet, + AnswerUpdateFormSet, + BodyPartForm, + ExaminationForm, + MarkAnatomyQuestionForm, + AnatomyQuestionForm, + StructureForm, + ExamForm, +) +from .models import ( + AnatomyQuestion, + BodyPart, + CidUserAnswer, + # Examination, + Structure, + Exam, + Answer, + # HalfMarkAnswers, + # IncorrectAnswers, +) +from generic.models import CidUser, Examination +from generic.views import ExamCloneMixin, ExamViews, GenericViewBase +from reversion.views import RevisionMixin + +from .decorators import user_is_author_or_anatomy_checker + +from .tables import AnatomyQuestionTable, AnatomyUserAnswerTable +from .filters import AnatomyQuestionFilter, AnatomyUserAnswerFilter + +from django_tables2 import SingleTableView, SingleTableMixin +from django_filters.views import FilterView + +from collections import defaultdict +import os +import json +import statistics +import plotly.express as px + +from helpers.images import image_as_base64 + +from django.template.defaulttags import register + +from generic.mixins import SuperuserRequiredMixin + +from rest_framework import viewsets, permissions + +from .serializers import ExamSerializer + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user.groups.filter(name="anatomy_checker").exists(): + return obj + if self.request.user not in obj.author.all(): + raise PermissionDenied() # or Http404 + return obj + + +@register.filter +def get_item(dictionary, key): + if dictionary is None: + return None + return dictionary.get(key) + + +def user_is_admin(user): + if user: + if user.pk == 1: + return True + return False + + +def question_list(request): + questions = AnatomyQuestion.objects.all() + + if not request.user.groups.filter(name="anatomy_checker").exists(): + questions = questions.filter(open_access=False) + # raise PermissionDenied() + + return render(request, "anatomy/question_list.html", {"questions": questions}) + + +@login_required +def answer_question(request, pk): + question = get_object_or_404(AnatomyQuestion, pk=pk) + answer = question.user_answers.filter( + user=request.user + ).first() # .filter(user=User) + if request.method == "POST": + if answer: + form = AnatomyAnswerForm(request.POST, instance=answer) + else: + form = AnatomyAnswerForm(request.POST) + if form.is_valid(): + answer = form.save(commit=False) + answer.user = request.user + answer.question = question + # answer.published_date = timezone.now() + answer.save() + return redirect("question_detail", pk=pk) + else: + form = AnatomyAnswerForm(instance=answer) + return render( + request, "anatomy/answer_question.html", {"form": form, "question": question} + ) + + +# @login_required +# def exam_take(request, pk, sk): +# """ +# Allows taking of the exam on the django server (when logged in) +# +# No longer used (deprecated in favour of using RTS) +# """ +# exam = get_object_or_404(Exam, pk=pk) +# +# questions = exam.exam_questions.all() +# +# try: +# question = questions[sk] +# except IndexError: +# raise Http404("Exam question does not exist") +# +# n = sk +# +# question_details = { +# "total": len(questions), +# "current": n + 1, +# } +# +# # Get data for flagged +# # answered_questions = UserAnswer.objects.filter(user=request.user).values_list("question__pk", flat=True) +# user_answer_data = UserAnswer.objects.filter(user=request.user).values_list( +# "question__pk", "answer", "flagged" +# ) +# # flagged_questions = UserAnswer.objects.filter(user=request.user, flagged=True).values_list("question__pk", flat=True) +# # flagged_questions I= UserAnswer.filter(user=request.user, ).values_list("question__pk", flat=True) +# +# answered_questions = set() +# flagged_questions = set() +# for ans_pk, answer, flagged in user_answer_data: +# if len(answer.strip()) > 0: +# answered_questions.add(ans_pk) +# if flagged: +# flagged_questions.add(ans_pk) +# +# # u = question.user_answers +# answer = question.user_answers.filter( +# user=request.user +# ).first() # .filter(user=User) +# if request.method == "POST": +# if answer: +# form = AnatomyAnswerForm(request.POST, instance=answer) +# else: +# form = AnatomyAnswerForm(request.POST) +# if form.is_valid(): +# answer = form.save(commit=False) +# answer.user = request.user +# answer.question = question +# # answer.published_date = timezone.now() +# answer.save() +# if "next" in request.POST: +# return redirect("anatomy:exam_take", pk=pk, sk=n + 1) +# elif "previous" in request.POST: +# return redirect("anatomy:exam_take", pk=pk, sk=n - 1) +# else: +# form = AnatomyAnswerForm(instance=answer) +# return render( +# request, +# "anatomy/exam.html", +# { +# "exam": exam, +# "form": form, +# "question": question, +# "question_details": question_details, +# "questions": questions, +# "flagged_questions": flagged_questions, +# "answered_questions": answered_questions, +# "answer": answer, +# }, +# ) + + +# def flag_question(request): +# pk = request.GET.get("pk", None) +# ans = UserAnswer.objects.filter(user=request.user, question__pk=pk).first() +# ans.flagged = not ans.flagged +# ans.save() +# data = {"flagged": ans.flagged} +# return JsonResponse(data) + + +def loadJsonAnswer(answer): + # Backend eid is just the object pk number + # in the frontend this is appended with the type + # e.g. anatomy/1 + + # TODO Update to only allow valid Candidates + eid = int(answer["eid"].split("/")[1]) + cid = int(answer["cid"]) + # As access is not restricted make sure the data appears valid + if (not isinstance(cid, int)) or ( + not isinstance(eid, int) or (not isinstance(answer["ans"], str)) + ): + return False, JsonResponse( + {"success": False, "error": "cid or eid or answers not defined"} + ) + + # The model should catch invalid data but this should be less intensive + max_int = 999999999999999999999 + if cid > max_int or eid > max_int: + return False, JsonResponse({"success": False, "error": "invalid cid"}) + + exam = get_object_or_404(Exam, pk=eid) + + + if not exam.check_cid_user(cid, answer["passcode"]): + return False, JsonResponse( + {"success": False, "error": "invalid cid / passcode"} + ) + + # if not exam.active: + # return False, JsonResponse( + # {"success": False, "error": "No active exam: {}".format(eid)} + # ) + + exiting_answers = CidUserAnswer.objects.filter( + question__id=answer["qid"], exam__id=eid, cid=cid + ) + + if not exiting_answers: + ans = CidUserAnswer(answer=answer["ans"], cid=cid) + ans.question_id = answer["qid"] + ans.exam_id = eid + ans.score = Answer.MarkOptions.UNMARKED + + ans.full_clean() + + ans.save() + else: + # Update an existing answer + # should never be more than one (famous last words) + ans = exiting_answers[0] + ans.answer = answer["ans"] + ans.score = Answer.MarkOptions.UNMARKED + ans.full_clean() + ans.save() + + return True, None + + +# @csrf_exempt +# def postExamAnswers(request): +# if request.is_ajax and request.method == "POST": +# +# n = 0 +# # horrible but it works +# #for k in request.POST.dict(): +# +# for answer in json.loads(request.POST.get("answers")): +# ret = loadJsonAnswer(answer) +# +# if ret is not True: +# return ret +# n = n + 1 +# +# # print(UserAnswer.objects.filter(exam__id=q["eid"])) +# # print(request.urlencode()) +# +# return JsonResponse({"success": True, "question_count": n}) +# return JsonResponse({"success": False, "error": "Invalid data"}) +# +# # return render(request, "anatomy/exam.html", {"exam" : exam, "question" : question}) + + +@login_required +def mark_all(request, exam_pk, sk): + return mark(request, exam_pk, sk, unmarked_exam_answers_only=False) + + +@login_required +def mark_review(request, exam_pk, sk): + return mark(request, exam_pk, sk, unmarked_exam_answers_only=False, review=True) + + +# @user_passes_test(user_is_admin, login_url="/accounts/login") +@login_required +def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): + exam = get_object_or_404(Exam, pk=exam_pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + mark_url = "anatomy:mark" + if review: + mark_url = "anatomy:mark_review" + elif not unmarked_exam_answers_only: + mark_url = "anatomy:mark_all" + + questions = exam.exam_questions.all() + + n = sk + + question_details = { + "total": len(questions), + "current": n + 1, + } + + try: + question = questions[sk] + except IndexError: + raise Http404("Exam question does not exist") + + if request.method == "POST": + + form = MarkAnatomyQuestionForm(request.POST) + # ******************************* + # TODO: convert to JSON request + # ******************************* + if form.is_valid(): + # If skip button is pressed skip the question without marking + if "skip" in request.POST: + return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) + + cd = form.cleaned_data + + marked_answers = json.loads(cd.get("marked_answers")) + + # This should probably use json (or something else...) + # ajax..... + to_run = ( + ("correct", Answer.MarkOptions.CORRECT), + ("half-correct", Answer.MarkOptions.HALF_MARK), + ("incorrect", Answer.MarkOptions.INCORRECT), + ) + + for status_string, status in to_run: + for ans in marked_answers[status_string]: + ans = ans.strip() + if ans == "": + continue + + a = Answer.objects.filter( + answer__iexact=ans, question_id=question.pk + ).first() + + if a is None: + a = Answer() + a.question_id = question.pk + a.answer = ans + + a.status = status + a.save() + + if "next" in request.POST: + return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) + elif "previous" in request.POST: + return redirect(mark_url, exam_pk=exam_pk, sk=n - 1) + + # Reset user answers (relies on the functional javascript) + unmarked_user_answers = set() + else: + form = MarkAnatomyQuestionForm() + + # correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT) + # half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK) + # incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT) + + correct_answers = [] + half_mark_answers = [] + incorrect_answers = [] + review_user_answers = [] + unmarked_answers_bool = False + unmarked_user_answers = [] + + if review: + unmarked_user_answers = question.get_user_answers( + exam_pk=exam_pk, include_normal=False + ) + elif unmarked_exam_answers_only: + unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk) + else: + unmarked_user_answers = question.get_unmarked_user_answers() + + if not unmarked_exam_answers_only: + correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT) + half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK) + incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT) + + if review: + for ans in unmarked_user_answers: + # duplicated from user answer model.... + marked_ans = question.answers.filter(answer_compare__iexact=ans).first() + mark = "unmarked" + if marked_ans is not None: + if marked_ans.status == Answer.MarkOptions.CORRECT: + mark = "correct" + elif marked_ans.status == Answer.MarkOptions.HALF_MARK: + mark = "half-correct" + elif marked_ans.status == Answer.MarkOptions.INCORRECT: + mark = "incorrect" + if mark == "unmarked": + unmarked_answers_bool = True + review_user_answers.append((ans, mark)) + + return render( + request, + "anatomy/mark.html", + { + "exam": exam, + "form": form, + "review": review, + "question": question, + "question_number": n, + "question_details": question_details, + "unmarked_answers_bool": unmarked_answers_bool, + "user_answers": unmarked_user_answers, + "review_user_answers": review_user_answers, + "correct_answers": correct_answers, + "half_mark_answers": half_mark_answers, + "incorrect_answers": incorrect_answers, + "unmarked_exam_answers_only": unmarked_exam_answers_only, + }, + ) + + +@login_required +def exam_scores_refresh(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + questions = exam.exam_questions.all() + + cids = CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) + + # Force a score update + for c in cids: + c.get_answer_score(cached=False) + + return render( + request, + "anatomy/base.html", + { + "simple_content": "Answer scores updated", + }, + ) + + +# @login_required +# def exam_scores_cid(request, pk): +# exam = get_object_or_404(Exam, pk=pk) +# +# if not exam.exam_mode: +# raise Http404("Packet not in exam mode") +# +# questions = exam.exam_questions.all() +# +# cids = ( +# CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) +# .order_by("cid") +# .values_list("cid", flat=True) +# .distinct() +# ) +# +# user_answers_and_marks = defaultdict(list) +# user_answers_marks = defaultdict(list) +# user_answers = defaultdict(list) +# user_names = {} +# +# by_question = defaultdict(list) +# unmarked = set() +# +# # Loop through all candidates +# for cid in cids: +# # Convoluted (probably...) +# user_names[cid] = cid +# for q in questions: +# # Get user answer +# s = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() +# if not s: +# # skip if no answer +# user_answers_marks[cid].append(0) +# user_answers[cid].append("") +# by_question[q].append(("", 0)) +# continue +# +# ans = s.answer +# answer_score = s.get_answer_score() +# if answer_score == "unmarked": +# index = exam.get_question_index(q) +# unmarked.add(index) +# user_answers[cid].append(ans) +# user_answers_marks[cid].append(answer_score) +# user_answers_and_marks[cid].append((ans, answer_score)) +# +# by_question[q].append((ans, answer_score)) +# +# user_scores = {} +# for user in user_answers_marks: +# user_scores[user] = sum( +# [i for i in user_answers_marks[user] if i != "unmarked"] +# ) +# +# user_scores_list = list(user_scores.values()) +# +# if len(user_scores_list) < 1: +# mean = 0 +# median = 0 +# mode = 0 +# fig_html = "" +# else: +# mean = statistics.mean(user_scores_list) +# median = statistics.median(user_scores_list) +# try: +# mode = statistics.mode(user_scores_list) +# except statistics.StatisticsError: +# mode = "No unique mode" +# +# df = user_scores_list +# fig = px.histogram( +# df, +# x=0, +# title="{}: distribution of scores".format(exam), +# labels={"0": "Score"}, +# height=400, +# width=600, +# ) +# fig_html = fig.to_html() +# +# max_score = len(questions) * 2 +# +# exam.stats_mean = mean +# exam.stats_median = median +# exam.stats_mode = mode +# +# exam.stats_candidates = len(user_scores_list) +# exam.stats_max_possible = max_score +# +# exam.stats_min = min(user_scores_list) +# exam.stats_max = max(user_scores_list) +# +# exam.stats_graph = fig_html +# exam.save() +# +# return render( +# request, +# "anatomy/exam_scores.html", +# { +# "cids": cids, +# "exam": exam, +# "unmarked": unmarked, +# "questions": questions, +# "by_question": by_question, +# "user_answers": dict(user_answers), +# "user_answers_marks": dict(user_answers_marks), +# "user_scores": user_scores, +# "user_scores_list": user_scores_list, +# "user_names": user_names, +# "user_answers_and_marks": user_answers_and_marks, +# "max_score": max_score, +# "mean": mean, +# "median": median, +# "mode": mode, +# "plot": fig_html, +# }, +# ) + + +def exam_scores_cid_user(request, pk, cid, passcode): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + if not exam.check_cid_user(cid, passcode, request): + raise Http404("Error accessing exam") + + questions = exam.exam_questions.all() + + answers_and_marks = [] + answers_marks = [] + answers = [] + + view_all_results = False + if request.user.groups.filter(name="view_all_results").exists(): + view_all_results = True + + for q in questions: + # Get user answer + user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() + if not user_answer: + # skip if no answer + answers_marks.append(0) + answers.append("") + answer_score = 0 + ans = "Not answered" + else: + ans = user_answer.answer + + answer_score = user_answer.get_answer_score() + + correct_answer = q.get_primary_answer() + + if not exam.publish_results and not view_all_results: + correct_answer = "*****" + answer_score = 0 + answers.append(ans) + answers_marks.append(answer_score) + answers_and_marks.append((ans, answer_score, correct_answer)) + + if "unmarked" in answers_marks: + 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) + else: + total_score = sum(answers_marks) + + max_score = len(questions) * 2 + + return render( + request, + "anatomy/exam_scores_user.html", + { + "exam": exam, + "cid": cid, + "passcode": passcode, + "questions": questions, + "answers": answers, + "answers_marks": answers_marks, + "total_score": total_score, + "max_score": max_score, + "answers_and_marks": answers_and_marks, + "view_all_results": view_all_results, + }, + ) + + +# @login_required +# def exam_scores(request, pk): +# exam = get_object_or_404(Exam, pk=pk) + +# questions = exam.exam_questions.all() + +# users = (CidUserAnswer.objects.filter( +# question__in=questions).values_list("user").distinct()) + +# user_answers_and_marks = defaultdict(list) +# user_answers_marks = defaultdict(list) +# user_answers = defaultdict(list) +# user_names = {} + +# by_question = defaultdict(list) + +# for u in users: +## Convoluted (probably...) +# user_names[u] = User.objects.filter(pk=u[0]).first().get_username() +# for q in questions: +# user_answer = q.user_answers.filter(user__in=u).first() +# if not user_answer: +# user_answers_marks[u].append(0) +# user_answers[u].append("") +# continue + +# ans = user_answer.answer +# answer_score = user_answer.get_answer_score() +# user_answers[u].append(ans) +# user_answers_marks[u].append(answer_score) +# user_answers_and_marks[u].append((ans, answer_score)) + +# by_question[q].append(ans) + +# user_scores = {} +# for user in user_answers_marks: +# user_scores[user] = sum(user_answers_marks[user]) + +# total = len(questions) + +# return render( +# request, +# "anatomy/exam_scores.html", +# { +# "exam": exam, +# "questions": questions, +# "by_question": by_question, +# "user_answers": dict(user_answers), +# "user_answers_marks": dict(user_answers_marks), +# "user_scores": user_scores, +# "user_names": user_names, +# "user_answers_and_marks": user_answers_and_marks, +# }, +# ) + +# def cid_scores(request, pk): +# #exam = get_object_or_404(Exam, pk=pk) +# +# # TODO:Need some kind of test for cid +# cid = pk +# +# #questions = exam.exam_questions.all() +# answers = CidUserAnswer.objects.filter( +# cid=cid) +# +# +# if not answers: +# raise Http404("cid not found") +# +# exam_ids = answers.values_list("exam").distinct() +# +# exams = Exam.objects.filter(id__in=exam_ids) +# +# +# return render( +# request, +# "anatomy/cid_scores.html", +# { +# "exams": exams, +# "cid": cid, +# }, +# ) + + +class AnatomyQuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): + model = AnatomyQuestion + form_class = AnatomyQuestionForm + + def get_form_kwargs(self): + kwargs = super(AnatomyQuestionCreateBase, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(AnatomyQuestionCreateBase, self).get_context_data(**kwargs) + if self.request.POST: + context["answer_formset"] = AnswerFormSet( + self.request.POST, self.request.FILES + ) + else: + context["answer_formset"] = AnswerFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + formset = context["answer_formset"] + + if formset.is_valid(): + response = super().form_valid(form) + formset.instance = self.object + formset.save() + # If the normal submit button is pressed we save as normal + if "submit" in self.request.POST: + return response + # else we redirect to the clone url + else: + return redirect("anatomy:question_clone", pk=self.object.pk) + + else: + return super().form_invalid(form) + + +class AnatomyQuestionCreate(AnatomyQuestionCreateBase): + + # initial = {'laterality': AnatomyQuestion.NONE} + + def get_initial(self): + if "pk" in self.kwargs: + initial = super().get_initial() + exam = get_object_or_404(Exam, pk=self.kwargs["pk"]) + + initial["exams"] = [exam.id] + + return initial + + +# # There has to be a better way... +# try: +# s = (i.pk for i in self.request.user.AnatomyQuestion_default.site.all()) +# self.initial.update({'site': s}) +# except AttributeError: +# pass +# return self.initial +# +# fields = '__all__' +# #fields = [ 'condition' ] +# #initial = {'date_of_death': '05/01/2018'} +# exclude = [ 'created_date', 'published_date' ] + +# self.object = form.save(commit=False) +# self.object.save() + +# form.instance.author.add(self.request.user.id) +# return super().form_valid(form) + + +# class AnatomyQuestionUpdate(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, +class AnatomyQuestionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView): + model = AnatomyQuestion + form_class = AnatomyQuestionForm + + def get_form_kwargs(self): + kwargs = super(AnatomyQuestionUpdate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(AnatomyQuestionUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["answer_formset"] = AnswerUpdateFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["answer_formset"].full_clean() + else: + context["answer_formset"] = AnswerUpdateFormSet(instance=self.object) + return context + + def form_valid(self, form): + + # save exam orders (there must be a better way to do this) + #exam_orders = {} + #for exam in self.object.exams.all(): + # exam_orders[exam] = list(exam.exam_questions.all()) + # print(exam_orders[exam]) + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + formset = context["answer_formset"] + # logger.debug(formset.is_valid()) + if formset.is_valid(): + response = super().form_valid(form) + formset.instance = self.object + formset.save() + + # restore exam orders + #for exam in self.object.exams.all(): + # if exam in exam_orders and self.object in exam_orders[exam]: + # print(exam_orders[exam]) + # exam.exam_questions.set(exam_orders[exam]) + # exam.save() + + return response + else: + return super().form_invalid(form) + + +class QuestionClone(AnatomyQuestionCreateBase): + """Clones a existing question""" + + def get_initial(self): + # print(self.request) + old_object = get_object_or_404(AnatomyQuestion, pk=self.kwargs["pk"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + # We don't want to clone the exam details + # exams = old_object.exams.all().values_list("id", flat=True) + + # initial_data["exams"] = list(exams) + + return initial_data + + +@login_required +def create_body_part(request): + form = BodyPartForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "anatomy/create_simple.html", {"form": form, "name": "BodyPart"} + ) + + +@csrf_exempt +def get_body_part_id(request): + if request.is_ajax(): + body_part_name = request.GET["body_part_name"] + body_part_id = BodyPart.objects.get(name=body_part_name).id + data = { + "body_part_id": body_part_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +@login_required +def create_examination(request): + form = ExaminationForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "anatomy/create_simple.html", {"form": form, "name": "Examination"} + ) + + +@csrf_exempt +def get_examination_id(request): + if request.is_ajax(): + examination_name = request.GET["examination_name"] + examination_id = Examination.objects.get(name=examination_name).id + data = { + "examination_id": examination_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +@login_required +def create_structure(request): + form = StructureForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "anatomy/create_simple.html", {"form": form, "name": "Structure"} + ) + + +@csrf_exempt +def get_structure_id(request): + if request.is_ajax(): + structure_name = request.GET["structure_name"] + structure_id = Structure.objects.get(name=structure_name).id + data = { + "structure_id": structure_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +class AnatomyQuestionView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = AnatomyQuestion + table_class = AnatomyQuestionTable + template_name = "anatomy/view.html" + + filterset_class = AnatomyQuestionFilter + + # def get_queryset(self): + # qs = super().get_queryset() + + # qs = qs.select_related('modality', 'question_type') + + # return qs + + +@user_is_author_or_anatomy_checker +def question_save_annotation(request, pk): + if request.is_ajax() and request.method == "POST": + question = get_object_or_404(AnatomyQuestion, pk=pk) + + question.image_annotations = request.POST.get("annotation") + print(question.image_annotations) + + question.save() + data = {"status": "success"} + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + +GenericExamViews = ExamViews( + Exam, AnatomyQuestion, Answer, CidUserAnswer, "anatomy", "anatomy", loadJsonAnswer +) + + +class UserAnswerView(SuperuserRequiredMixin, DetailView): + model = CidUserAnswer + + +class UserAnswerTableView(SuperuserRequiredMixin, SingleTableMixin, FilterView): + model = CidUserAnswer + table_class = AnatomyUserAnswerTable + template_name = "anatomy/user_answer_question_view.html" + + filterset_class = AnatomyUserAnswerFilter + + +class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): + model = CidUserAnswer + template_name = "user_answer_delete.html" + success_url = reverse_lazy("anatomy:user_answer_table_view") + + +class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): + model = AnatomyQuestion + success_url = reverse_lazy("anatomy:question_list") + + +class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + return super().form_valid(form) + + +class ExamUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + return super().form_valid(form) + + +class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): + # queryset = Exam.objects.all().order_by('name') + serializer_class = ExamSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + """ + This view should return a list exams available to a user. + """ + user = self.request.user + if user.groups.filter(name="anatomy_checker").exists(): + return Exam.objects.all() + + return Exam.objects.filter(author__id=user.id) + + +class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Exam + template_name = "exam_confirm_delete.html" + success_url = reverse_lazy("anatomy:index") + + +GenericViews = GenericViewBase("anatomy", AnatomyQuestion, CidUserAnswer, Exam) + + +class ExamClone(ExamCloneMixin, ExamCreate): + """Clone exam view""" + + +class StructureAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + # Don't forget to filter out results depending on the visitor ! + if not self.request.user.is_authenticated: + return Structure.objects.none() + + qs = Structure.objects.all() + + if self.q: + # This raises a fielderror which breaks creating a new item if not caught + try: + qs = qs.filter(name__istartswith=self.q) + except FieldError: + return Structure.objects.none() + + return qs diff --git a/atlas/admin.py b/atlas/admin.py index ee0fb292..143297e0 100755 --- a/atlas/admin.py +++ b/atlas/admin.py @@ -1,112 +1,112 @@ -from django.contrib import admin -from .models import ( - Case, - CaseDetail, - CaseCollection, - Differential, - Series, - SeriesImage, - Condition, - Subspecialty, - Finding, - SeriesFinding, - Structure, - PathologicalProcess, - Presentation, -) - -import tagulous.admin - -from django.forms import ModelForm - -from reversion.admin import VersionAdmin -from django.forms.widgets import RadioSelect - -from tinymce.widgets import TinyMCE - -# from generic.models import Examination, Sign, Site, Condition -from generic.models import Examination - -# Register your models here. -# admin.site.register(Examination) -# admin.site.register(Site) -admin.site.register(SeriesImage) -admin.site.register(Condition) -admin.site.register(Subspecialty) -admin.site.register(Finding) -admin.site.register(Structure) -admin.site.register(SeriesFinding) -admin.site.register(Differential) -admin.site.register(PathologicalProcess) -admin.site.register(Presentation) -admin.site.register(CaseDetail) - - -class DifferentialInline(admin.TabularInline): - model = Differential - -class CasesInline(admin.TabularInline): - model = CaseDetail - -class CaseCollectionAdminForm(ModelForm): - class Meta: - model = CaseCollection - - exclude = [] - - -class CaseCollectionAdmin(VersionAdmin): - form = CaseCollectionAdminForm - - save_on_top = True - save_as = True - view_on_site = True - - inlines = (CasesInline,) - - -admin.site.register(CaseCollection, CaseCollectionAdmin) - -class AtlasAdminForm(ModelForm): - class Meta: - model = Case - - fields = [ - "title", - "description", - "history", - "discussion", - "condition", - "differential", - "subspecialty", - "author", - "series", - ] - - widgets = { - # "normal": RadioSelect( - # choices=[(True, 'Yes'), - # (False, 'No')]) - "history": TinyMCE(attrs={"cols": 80, "rows": 30}), - "discussion": TinyMCE(attrs={"cols": 80, "rows": 30}), - } - - -class AtlasAdmin(VersionAdmin): - form = AtlasAdminForm - - save_on_top = True - save_as = True - view_on_site = True - - inlines = (DifferentialInline,) - - -admin.site.register(Case, AtlasAdmin) - - -class SeriesAdmin(VersionAdmin): - pass - - -admin.site.register(Series, SeriesAdmin) +from django.contrib import admin +from .models import ( + Case, + CaseDetail, + CaseCollection, + Differential, + Series, + SeriesImage, + Condition, + Subspecialty, + Finding, + SeriesFinding, + Structure, + PathologicalProcess, + Presentation, +) + +import tagulous.admin + +from django.forms import ModelForm + +from reversion.admin import VersionAdmin +from django.forms.widgets import RadioSelect + +from tinymce.widgets import TinyMCE + +# from generic.models import Examination, Sign, Site, Condition +from generic.models import Examination + +# Register your models here. +# admin.site.register(Examination) +# admin.site.register(Site) +admin.site.register(SeriesImage) +admin.site.register(Condition) +admin.site.register(Subspecialty) +admin.site.register(Finding) +admin.site.register(Structure) +admin.site.register(SeriesFinding) +admin.site.register(Differential) +admin.site.register(PathologicalProcess) +admin.site.register(Presentation) +admin.site.register(CaseDetail) + + +class DifferentialInline(admin.TabularInline): + model = Differential + +class CasesInline(admin.TabularInline): + model = CaseDetail + +class CaseCollectionAdminForm(ModelForm): + class Meta: + model = CaseCollection + + exclude = [] + + +class CaseCollectionAdmin(VersionAdmin): + form = CaseCollectionAdminForm + + save_on_top = True + save_as = True + view_on_site = True + + inlines = (CasesInline,) + + +admin.site.register(CaseCollection, CaseCollectionAdmin) + +class AtlasAdminForm(ModelForm): + class Meta: + model = Case + + fields = [ + "title", + "description", + "history", + "discussion", + "condition", + "differential", + "subspecialty", + "author", + "series", + ] + + widgets = { + # "normal": RadioSelect( + # choices=[(True, 'Yes'), + # (False, 'No')]) + "history": TinyMCE(attrs={"cols": 80, "rows": 30}), + "discussion": TinyMCE(attrs={"cols": 80, "rows": 30}), + } + + +class AtlasAdmin(VersionAdmin): + form = AtlasAdminForm + + save_on_top = True + save_as = True + view_on_site = True + + inlines = (DifferentialInline,) + + +admin.site.register(Case, AtlasAdmin) + + +class SeriesAdmin(VersionAdmin): + pass + + +admin.site.register(Series, SeriesAdmin) diff --git a/atlas/migrations/0025_auto_20220321_1602.py b/atlas/migrations/0025_auto_20220321_1602.py index d7c0ef6a..f93c1ca2 100644 --- a/atlas/migrations/0025_auto_20220321_1602.py +++ b/atlas/migrations/0025_auto_20220321_1602.py @@ -1,36 +1,36 @@ -# Generated by Django 3.2.10 on 2022-03-21 16:02 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('atlas', '0024_alter_case_previous_case'), - ] - - operations = [ - migrations.CreateModel( - name='CaseCollection', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ('open_access', models.BooleanField(default=True, help_text='If a collection should be freely available to browse')), - ], - ), - migrations.CreateModel( - name='CaseDetail', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('sort_order', models.IntegerField()), - ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.case')), - ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.casecollection')), - ], - ), - migrations.AddField( - model_name='casecollection', - name='cases', - field=models.ManyToManyField(through='atlas.CaseDetail', to='atlas.Case'), - ), - ] +# Generated by Django 3.2.10 on 2022-03-21 16:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0024_alter_case_previous_case'), + ] + + operations = [ + migrations.CreateModel( + name='CaseCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('open_access', models.BooleanField(default=True, help_text='If a collection should be freely available to browse')), + ], + ), + migrations.CreateModel( + name='CaseDetail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField()), + ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.case')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='atlas.casecollection')), + ], + ), + migrations.AddField( + model_name='casecollection', + name='cases', + field=models.ManyToManyField(through='atlas.CaseDetail', to='atlas.Case'), + ), + ] diff --git a/atlas/migrations/0026_auto_20220321_1612.py b/atlas/migrations/0026_auto_20220321_1612.py index a8a0b2f9..7d28be1f 100644 --- a/atlas/migrations/0026_auto_20220321_1612.py +++ b/atlas/migrations/0026_auto_20220321_1612.py @@ -1,23 +1,23 @@ -# Generated by Django 3.2.10 on 2022-03-21 16:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('atlas', '0025_auto_20220321_1602'), - ] - - operations = [ - migrations.AlterField( - model_name='case', - name='open_access', - field=models.BooleanField(default=True, help_text='If a case should be freely available to browse'), - ), - migrations.AlterField( - model_name='series', - name='open_access', - field=models.BooleanField(default=True, help_text='If a series should be freely available to browse'), - ), - ] +# Generated by Django 3.2.10 on 2022-03-21 16:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0025_auto_20220321_1602'), + ] + + operations = [ + migrations.AlterField( + model_name='case', + name='open_access', + field=models.BooleanField(default=True, help_text='If a case should be freely available to browse'), + ), + migrations.AlterField( + model_name='series', + name='open_access', + field=models.BooleanField(default=True, help_text='If a series should be freely available to browse'), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index 1797cd52..2d842cbd 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -1,568 +1,575 @@ -import json -from rad.settings import REMOTE_URL -from django.db.models.fields.files import ImageField -from django.db.models.fields.related import ForeignKey -from django.db import models -from django.shortcuts import get_object_or_404 -from django.utils import timezone -import tagulous -import tagulous.models - - -from django.core.files.storage import FileSystemStorage -from django.conf import settings -from django.utils.html import format_html - -from django.urls import reverse - -from django.utils.translation import ugettext_lazy as _ -from django.utils.html import mark_safe - -from django.core.exceptions import ValidationError - -from sortedm2m.fields import SortedManyToManyField - -import string -from collections import defaultdict -from helpers.images import image_as_base64, pretty_print_dicom - -from anatomy.models import Modality - -from generic.models import ( - Examination, - # Condition, - ExamBase, - Plane, - Contrast, - QuestionNote, -) - -# from generic.models import Examination, Site, Condition, Sign - -from easy_thumbnails.files import get_thumbnailer -from easy_thumbnails.exceptions import InvalidImageFormatError - -import pydicom -import pydicom.errors -import datetime -from django.utils import timezone - -import reversion - -from django.contrib.contenttypes.fields import GenericRelation - - -image_storage = FileSystemStorage( - # Physical file location ROOT - location="{0}atlas/".format(settings.MEDIA_ROOT), - # Url for file - base_url="{0}atlas/".format(settings.MEDIA_URL), -) - - -def image_directory_path(instance, filename): - return "atlas/picture/{0}".format(filename) - - -def findMiddle(input_list): - middle = float(len(input_list)) / 2 - if middle % 2 != 0: - return input_list[int(middle - 0.5)] - else: - return input_list[int(middle)] - return (input_list[int(middle)], input_list[int(middle - 1)]) - - -class SynMixin(object): - # class Meta: - # abstract = True - - def __str__(self) -> str: - if self.primary: - if self.synonym.count() == 0: - return self.name - else: - synonyms = ",".join([i.name for i in self.synonym.all()]) - return f"{self.name} ({synonyms})" - else: - return f"{self.name} [syn]" - - def get_synonym(self): - if self.primary: - return "[Primary]" - else: - s = self.synonym.filter(primary=True).values_list("name", flat=True) - return ", ".join(s) - - def get_synonym_link(self): - if self.primary: - return "[Primary]" - else: - syns = self.synonym.filter(primary=True) - return ", ".join( - [f"{s.name}" for s in syns] - ) - - def get_link(self): - return format_html("{}", self.get_absolute_url(), self.name) - - -class Finding(SynMixin, models.Model): - name = models.CharField(max_length=255, unique=True) - synonym = models.ManyToManyField("self", blank=True) - - primary = models.BooleanField(default="True") - - def get_absolute_url(self): - return reverse("atlas:finding_detail", kwargs={"pk": self.pk}) - - -class Condition(SynMixin, models.Model): - name = models.CharField(max_length=255, unique=True) - - synonym = models.ManyToManyField("self", blank=True) - parent = models.ManyToManyField("self", blank=True) - - primary = models.BooleanField(default="True") - - subspecialty = models.ManyToManyField("subspecialty", blank=True) - - def get_absolute_url(self): - return reverse("atlas:condition_detail", kwargs={"pk": self.pk}) - - -class Presentation(models.Model): - name = models.CharField(max_length=255) - - subspecialty = models.ManyToManyField("subspecialty", blank=True) - - def __str__(self) -> str: - if self.subspecialty: - return ( - f"{self.name} ({', '.join([str(i) for i in self.subspecialty.all()])})" - ) - return self.name - - def get_absolute_url(self): - return reverse("atlas:presentation_detail", kwargs={"pk": self.pk}) - - def get_link(self): - return format_html("{}", self.get_absolute_url(), self.name) - - -class Subspecialty(models.Model): - name = models.CharField(max_length=255) - - def __str__(self) -> str: - return self.name - - def get_absolute_url(self): - return reverse("atlas:subspecialty_detail", kwargs={"pk": self.pk}) - - def get_link(self): - return format_html("{}", self.get_absolute_url(), self.name) - - -class PathologicalProcess(models.Model): - name = models.CharField(max_length=255) - - def __str__(self) -> str: - return self.name - - def get_absolute_url(self): - return reverse("atlas:pathological_process_detail", kwargs={"pk": self.pk}) - - def get_link(self): - return format_html("{}", self.get_absolute_url(), self.name) - - -class Differential(models.Model): - condition = models.ForeignKey(Condition, on_delete=models.CASCADE) - case = models.ForeignKey( - "Case", on_delete=models.CASCADE, related_name="differentialcase" - ) - text = models.TextField(null=True, blank=True) - - -class Structure(SynMixin, models.Model): - name = models.CharField(max_length=255, unique=True) - - synonym = models.ManyToManyField("self", blank=True) - - primary = models.BooleanField(default="True") - - def get_absolute_url(self): - return reverse("atlas:structure_detail", kwargs={"pk": self.pk}) - - -@reversion.register -class Case(models.Model): - # class SubspecialtyChoices(models.TextChoices): - # BREAST = "BR", _("Breast") - # CARDIAC = "CA", _("Cardiac") - # GASTRO = "GI", _("Gastrointestinal and hepatobiliary") - # HEADNECK = "HN", _("Head and Neck") - # MSK = "MS", _("Musculoskeletal") - # NEURO = "NE", _("Neuroradiology") - # OBSGYN = "OG", _("Obstectric and Gynaecological") - # PAED = "PA", _("Paediatric") - # URO = "UR", _("Uroradiology") - # VASC = "VA", _("Vascular") - # HAEMONC = "HA", _("Haemotology and Oncology") - - title = models.CharField(max_length=255, help_text="Title of the case", default="") - # author = models.ForeignKey('auth.User', on_delete=models.CASCADE) - # image = models.ImageField() - description = models.TextField( - blank=True, - help_text="Description of the case", - ) - - history = models.TextField( - null=True, blank=True, help_text="A (brief) summary of the relevant history" - ) - discussion = models.TextField(null=True, blank=True) - report = models.TextField( - null=True, blank=True, help_text="A model (sample) report for the case." - ) - - # findings = models.TextField(null=True, blank=True) - - # subspecialty = models.CharField(max_length=2, choices=SubspecialtyChoices.choices) - subspecialty = models.ManyToManyField(Subspecialty, blank=True) - - condition = models.ManyToManyField(Condition, blank=True) - - presentation = models.ManyToManyField(Presentation, blank=True) - - pathological_process = models.ManyToManyField(PathologicalProcess, blank=True) - - differential = models.ManyToManyField( - Condition, through=Differential, related_name="casedifferential" - ) - - verified = models.BooleanField(default=False) - created_date = models.DateTimeField(default=timezone.now) - published_date = models.DateTimeField(blank=True, null=True) - author = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - help_text="Author of the case", - related_name="atlas_authored_cases", - ) - - editor = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - related_name="atlas_edited_cases", - ) - - scrapped = models.BooleanField( - default=False, help_text="Question has been scrapped and will not be shown" - ) - - open_access = models.BooleanField( - help_text="If a case should be freely available to browse", default=True - ) - - series = SortedManyToManyField("Series", related_name="case") - - notes = GenericRelation(QuestionNote) - - previous_case = models.OneToOneField("Case", on_delete=models.SET_NULL, null=True, related_name="next_case", blank=True) - - # question_file = models.FileField(upload_to=question_file_directory_path, blank=True, null=True) - - def get_absolute_url(self): - return reverse("atlas:case_detail", kwargs={"pk": self.pk}) - - def get_authors(self): - """Returns a comma seperated text list of authors""" - authors = ", ".join([i.username for i in self.author.all()]) - return authors - - def get_author_objects(self): - """Returns a comma seperated text list of authors""" - authors = [i for i in self.author.all()] - return authors - - def get_link(self): - return format_html("{}", self.get_absolute_url(), str(self)) - - def __str__(self): - return f"{self.pk}:{self.title}" - - def get_series_blocks(self): - html = "" - for s in self.series.all(): - html += s.get_block() - - return html - - - def get_long_str(self): - examinations = [series.get_examination() for series in self.series.all()] - return f"{self.pk}:{self.title} {'/'.join([str(c) for c in self.condition.all()])} [{', '.join(examinations)}]" - - -class SeriesImage(models.Model): - image = models.FileField(upload_to=image_directory_path) - position = models.IntegerField(default=0) - upload_filename = models.CharField(max_length=255, blank=True) - series = models.ForeignKey( - "Series", related_name="images", on_delete=models.SET_NULL, null=True - ) - - class Meta: - ordering = ["position"] - - def get_dicom_info(self): - try: - info = pretty_print_dicom(pydicom.read_file(self.image)) - except pydicom.errors.InvalidDicomError: - info = "File is not a dicom." - return info - - -class SeriesFinding(models.Model): - - series = models.ForeignKey( - "Series", related_name="findings", on_delete=models.SET_NULL, null=True - ) - description = models.TextField( - null=True, blank=True, help_text="Findings on the series / stack" - ) - findings = models.ManyToManyField(Finding, blank=True) - structures = models.ManyToManyField(Structure, blank=True) - annotation_json = models.TextField(null=True, blank=True) - viewport_json = models.TextField(null=True, blank=True) - - def __str__(self) -> str: - findings = self.findings.all().values_list("name") - return f"{self.series.id}/{findings}/{self.description}" - - def annotation_as_string(self) -> str: - return json.dumps(self.annotation_json) - - -@reversion.register -class Series(models.Model): - modality = models.ForeignKey( - Modality, - related_name="atlas_series_modality", - on_delete=models.SET_NULL, - null=True, - ) - examination = models.ForeignKey( - Examination, - help_text="Name of the examination", - related_name="atlas_series_examination", - on_delete=models.SET_NULL, - null=True, - ) - plane = models.ForeignKey( - Plane, - help_text="Plane of the examination", - related_name="atlas_series_plane", - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - contrast = models.ForeignKey( - Contrast, - help_text="MRI / CT contrast", - related_name="atlas_series_contrast", - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - description = models.TextField( - blank=True, - help_text="Description of stack", - ) - - author = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - related_name="series", - ) - - # findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack") - - open_access = models.BooleanField( - help_text="If a series should be freely available to browse", default=True - ) - - def __str__(self): - if self.case: - case_id = ", ".format([case.pk for case in self.case.all()]) - # case_id = self.case.pk - else: - case_id = "None" - return "{}/{} : {} [{}]".format( - self.pk, self.get_examination_full(), self.description, case_id - ) - - def get_absolute_url(self): - return reverse("series:question_detail", kwargs={"pk": self.pk}) - - # def get_string(self): - # examination = self.get_examination_full() - # return - - def get_author_objects(self): - """Returns a comma seperated text list of authors""" - if self.author: - return self.author.all() - else: - return ["None"] - - def get_author_display(self): - return ", ".join([i.username for i in self.get_author_objects()]) - - def get_examination(self): - """Returns a comma seperated text list of regions""" - return str(self.examination) - - def get_examination_full(self): - examination = "" - plane = "" - contrast = "" - - if self.examination: - examination = self.examination - if self.plane: - plane = " {}".format(self.plane) - if self.contrast: - contrast = " {}".format(self.contrast) - return "{}{}{}".format(examination, plane, contrast) - - def get_absolute_url(self): - return reverse("atlas:series_detail", kwargs={"pk": self.pk}) - - def get_image_urls(self, findings=False): - images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()] - - return ",".join(images) - - def get_image_url_array(self, findings=False): - images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()] - - return json.dumps(images) - - def get_thumbnail(self): - images = self.images.all() - - if len(images) < 1: - return "No images", 0 - - img = findMiddle(images).image - try: - thumbnailer = get_thumbnailer(img) - thumbnail = thumbnailer["exam-list"] - except InvalidImageFormatError: - return format_html('Invalid image url', img), len( - images - ) - return format_html('', thumbnail), len(images) - - def get_thumbnail_link(self): - return format_html( - "{}", self.get_absolute_url(), self.get_thumbnail()[0] - ) - - def get_block(self): - examination = self.get_examination_full() - thumb, image_number = self.get_thumbnail() - return format_html( - "
{}
{}
Images: {}
", examination, thumb, image_number - ) - - def order_by_upload_filename(self): - images = self.images.all() - - filenames = [] - map = {} - - for i in images: - filenames.append(i.upload_filename) - map[i.upload_filename] = i - - filenames = sorted(filenames) - - n = 1 - for f in filenames: - i = map[f] - i.position = n - i.save() - n = n + 1 - - def order_by_dicom(self, field="SliceLocation"): - images = self.images.all() - - files = [] - - for i in images: - files.append((i, pydicom.dcmread(i.image.path))) - - # print("file count: {}".format(len(files))) - - # skip files with no SliceLocation (eg scout views) - slices = [] - map = {} - skipcount = 0 - for i, f in files: - if hasattr(f, field): - slices.append(f) - # map[f.SliceLocation] = i - map[f[field].value] = i - else: - skipcount = skipcount + 1 - - print("skipped, no {}: {}".format(field, skipcount)) - - # ensure they are in the correct order - slices = sorted(slices, key=lambda s: s[field].value) - - # print(slices) - n = 1 - for f in slices: - i = map[f[field].value] - i.position = n - i.save() - n = n + 1 - - def get_total_image_size(self): - images = self.images.all() - - size = 0 - - for i in images: - size += i.image.size - - return size - - -class CaseCollection(models.Model): - name = models.CharField(max_length=255, unique=True) - - cases = models.ManyToManyField(Case, through="CaseDetail") - - open_access = models.BooleanField( - help_text="If a collection should be freely available to browse", default=True - ) - - def get_absolute_url(self): - return reverse("atlas:collection_detail_view", kwargs={"pk": self.pk}) - - def __str__(self) -> str: - return self.name - -class CaseDetail(models.Model): - case = models.ForeignKey(Case, on_delete=models.CASCADE) - collection = models.ForeignKey(CaseCollection, on_delete=models.CASCADE) - - sort_order = models.IntegerField() - - class Meta: - ordering = ("sort_order",) +import json +from rad.settings import REMOTE_URL +from django.db.models.fields.files import ImageField +from django.db.models.fields.related import ForeignKey +from django.db import models +from django.shortcuts import get_object_or_404 +from django.utils import timezone +import tagulous +import tagulous.models + + +from django.core.files.storage import FileSystemStorage +from django.conf import settings +from django.utils.html import format_html + +from django.urls import reverse + +from django.utils.translation import ugettext_lazy as _ +from django.utils.html import mark_safe + +from django.core.exceptions import ValidationError + +from sortedm2m.fields import SortedManyToManyField + +import string +from collections import defaultdict +from helpers.images import image_as_base64, pretty_print_dicom + +from anatomy.models import Modality + +from generic.models import ( + Examination, + # Condition, + ExamBase, + Plane, + Contrast, + QuestionNote, +) + +# from generic.models import Examination, Site, Condition, Sign + +from easy_thumbnails.files import get_thumbnailer +from easy_thumbnails.exceptions import InvalidImageFormatError + +import pydicom +import pydicom.errors +import datetime +from django.utils import timezone + +import reversion + +from django.contrib.contenttypes.fields import GenericRelation + + +image_storage = FileSystemStorage( + # Physical file location ROOT + location="{0}atlas/".format(settings.MEDIA_ROOT), + # Url for file + base_url="{0}atlas/".format(settings.MEDIA_URL), +) + + +def image_directory_path(instance, filename): + return "atlas/picture/{0}".format(filename) + + +def findMiddle(input_list): + middle = float(len(input_list)) / 2 + if middle % 2 != 0: + return input_list[int(middle - 0.5)] + else: + return input_list[int(middle)] + return (input_list[int(middle)], input_list[int(middle - 1)]) + + +class SynMixin(object): + # class Meta: + # abstract = True + + def __str__(self) -> str: + if self.primary: + if self.synonym.count() == 0: + return self.name + else: + synonyms = ",".join([i.name for i in self.synonym.all()]) + return f"{self.name} ({synonyms})" + else: + return f"{self.name} [syn]" + + def get_synonym(self): + if self.primary: + return "[Primary]" + else: + s = self.synonym.filter(primary=True).values_list("name", flat=True) + return ", ".join(s) + + def get_synonym_link(self): + if self.primary: + return "[Primary]" + else: + syns = self.synonym.filter(primary=True) + return ", ".join( + [f"
{s.name}" for s in syns] + ) + + def get_link(self): + return format_html("{}", self.get_absolute_url(), self.name) + + +class Finding(SynMixin, models.Model): + name = models.CharField(max_length=255, unique=True) + synonym = models.ManyToManyField("self", blank=True) + + primary = models.BooleanField(default="True") + + def get_absolute_url(self): + return reverse("atlas:finding_detail", kwargs={"pk": self.pk}) + + +class Condition(SynMixin, models.Model): + name = models.CharField(max_length=255, unique=True) + + synonym = models.ManyToManyField("self", blank=True) + parent = models.ManyToManyField("self", blank=True) + + primary = models.BooleanField(default="True") + + subspecialty = models.ManyToManyField("subspecialty", blank=True) + + def get_absolute_url(self): + return reverse("atlas:condition_detail", kwargs={"pk": self.pk}) + + +class Presentation(models.Model): + name = models.CharField(max_length=255) + + subspecialty = models.ManyToManyField("subspecialty", blank=True) + + def __str__(self) -> str: + if self.subspecialty: + return ( + f"{self.name} ({', '.join([str(i) for i in self.subspecialty.all()])})" + ) + return self.name + + def get_absolute_url(self): + return reverse("atlas:presentation_detail", kwargs={"pk": self.pk}) + + def get_link(self): + return format_html("{}", self.get_absolute_url(), self.name) + + +class Subspecialty(models.Model): + name = models.CharField(max_length=255) + + def __str__(self) -> str: + return self.name + + def get_absolute_url(self): + return reverse("atlas:subspecialty_detail", kwargs={"pk": self.pk}) + + def get_link(self): + return format_html("{}", self.get_absolute_url(), self.name) + + +class PathologicalProcess(models.Model): + name = models.CharField(max_length=255) + + def __str__(self) -> str: + return self.name + + def get_absolute_url(self): + return reverse("atlas:pathological_process_detail", kwargs={"pk": self.pk}) + + def get_link(self): + return format_html("{}", self.get_absolute_url(), self.name) + + +class Differential(models.Model): + condition = models.ForeignKey(Condition, on_delete=models.CASCADE) + case = models.ForeignKey( + "Case", on_delete=models.CASCADE, related_name="differentialcase" + ) + text = models.TextField(null=True, blank=True) + + +class Structure(SynMixin, models.Model): + name = models.CharField(max_length=255, unique=True) + + synonym = models.ManyToManyField("self", blank=True) + + primary = models.BooleanField(default="True") + + def get_absolute_url(self): + return reverse("atlas:structure_detail", kwargs={"pk": self.pk}) + + +@reversion.register +class Case(models.Model): + # class SubspecialtyChoices(models.TextChoices): + # BREAST = "BR", _("Breast") + # CARDIAC = "CA", _("Cardiac") + # GASTRO = "GI", _("Gastrointestinal and hepatobiliary") + # HEADNECK = "HN", _("Head and Neck") + # MSK = "MS", _("Musculoskeletal") + # NEURO = "NE", _("Neuroradiology") + # OBSGYN = "OG", _("Obstectric and Gynaecological") + # PAED = "PA", _("Paediatric") + # URO = "UR", _("Uroradiology") + # VASC = "VA", _("Vascular") + # HAEMONC = "HA", _("Haemotology and Oncology") + + title = models.CharField(max_length=255, help_text="Title of the case", default="") + # author = models.ForeignKey('auth.User', on_delete=models.CASCADE) + # image = models.ImageField() + description = models.TextField( + blank=True, + help_text="Description of the case", + ) + + history = models.TextField( + null=True, blank=True, help_text="A (brief) summary of the relevant history" + ) + discussion = models.TextField(null=True, blank=True) + report = models.TextField( + null=True, blank=True, help_text="A model (sample) report for the case." + ) + + # findings = models.TextField(null=True, blank=True) + + # subspecialty = models.CharField(max_length=2, choices=SubspecialtyChoices.choices) + subspecialty = models.ManyToManyField(Subspecialty, blank=True) + + condition = models.ManyToManyField(Condition, blank=True) + + presentation = models.ManyToManyField(Presentation, blank=True) + + pathological_process = models.ManyToManyField(PathologicalProcess, blank=True) + + differential = models.ManyToManyField( + Condition, through=Differential, related_name="casedifferential" + ) + + verified = models.BooleanField(default=False) + created_date = models.DateTimeField(default=timezone.now) + published_date = models.DateTimeField(blank=True, null=True) + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + help_text="Author of the case", + related_name="atlas_authored_cases", + ) + + editor = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="atlas_edited_cases", + ) + + scrapped = models.BooleanField( + default=False, help_text="Question has been scrapped and will not be shown" + ) + + open_access = models.BooleanField( + help_text="If a case should be freely available to browse", default=True + ) + + series = SortedManyToManyField("Series", related_name="case") + + notes = GenericRelation(QuestionNote) + + previous_case = models.OneToOneField("Case", on_delete=models.SET_NULL, null=True, related_name="next_case", blank=True) + + # question_file = models.FileField(upload_to=question_file_directory_path, blank=True, null=True) + + def get_absolute_url(self): + return reverse("atlas:case_detail", kwargs={"pk": self.pk}) + + def get_authors(self): + """Returns a comma seperated text list of authors""" + authors = ", ".join([i.username for i in self.author.all()]) + return authors + + def get_author_objects(self): + """Returns a comma seperated text list of authors""" + authors = [i for i in self.author.all()] + return authors + + def get_link(self): + return format_html("{}", self.get_absolute_url(), str(self)) + + def __str__(self): + return f"{self.pk}:{self.title}" + + def get_series_blocks(self): + html = "" + for s in self.series.all(): + html += s.get_block() + + return html + + + def get_long_str(self): + examinations = [series.get_examination() for series in self.series.all()] + return f"{self.pk}:{self.title} {'/'.join([str(c) for c in self.condition.all()])} [{', '.join(examinations)}]" + + +class SeriesImage(models.Model): + image = models.FileField(upload_to=image_directory_path) + position = models.IntegerField(default=0) + upload_filename = models.CharField(max_length=255, blank=True) + series = models.ForeignKey( + "Series", related_name="images", on_delete=models.SET_NULL, null=True + ) + + class Meta: + ordering = ["position"] + + def get_dicom_info(self): + try: + info = pretty_print_dicom(pydicom.read_file(self.image)) + except pydicom.errors.InvalidDicomError: + info = "File is not a dicom." + return info + + +class SeriesFinding(models.Model): + + series = models.ForeignKey( + "Series", related_name="findings", on_delete=models.SET_NULL, null=True + ) + description = models.TextField( + null=True, blank=True, help_text="Findings on the series / stack" + ) + findings = models.ManyToManyField(Finding, blank=True) + structures = models.ManyToManyField(Structure, blank=True) + annotation_json = models.TextField(null=True, blank=True) + viewport_json = models.TextField(null=True, blank=True) + + def __str__(self) -> str: + findings = self.findings.all().values_list("name") + return f"{self.series.id}/{findings}/{self.description}" + + def annotation_as_string(self) -> str: + return json.dumps(self.annotation_json) + + +@reversion.register +class Series(models.Model): + modality = models.ForeignKey( + Modality, + related_name="atlas_series_modality", + on_delete=models.SET_NULL, + null=True, + ) + examination = models.ForeignKey( + Examination, + help_text="Name of the examination", + related_name="atlas_series_examination", + on_delete=models.SET_NULL, + null=True, + ) + plane = models.ForeignKey( + Plane, + help_text="Plane of the examination", + related_name="atlas_series_plane", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + contrast = models.ForeignKey( + Contrast, + help_text="MRI / CT contrast", + related_name="atlas_series_contrast", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + description = models.TextField( + blank=True, + help_text="Description of stack", + ) + + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="series", + ) + + # findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack") + + open_access = models.BooleanField( + help_text="If a series should be freely available to browse", default=True + ) + + def __str__(self): + if self.case: + case_id = ", ".format([case.pk for case in self.case.all()]) + # case_id = self.case.pk + else: + case_id = "None" + return "{}/{} : {} [{}]".format( + self.pk, self.get_examination_full(), self.description, case_id + ) + + def get_absolute_url(self): + return reverse("series:question_detail", kwargs={"pk": self.pk}) + + # def get_string(self): + # examination = self.get_examination_full() + # return + + def get_author_objects(self): + """Returns a comma seperated text list of authors""" + if self.author: + return self.author.all() + else: + return ["None"] + + def get_author_display(self): + return ", ".join([i.username for i in self.get_author_objects()]) + + def get_examination(self): + """Returns a comma seperated text list of regions""" + return str(self.examination) + + def get_examination_full(self): + examination = "" + plane = "" + contrast = "" + + if self.examination: + examination = self.examination + if self.plane: + plane = " {}".format(self.plane) + if self.contrast: + contrast = " {}".format(self.contrast) + return "{}{}{}".format(examination, plane, contrast) + + def get_absolute_url(self): + return reverse("atlas:series_detail", kwargs={"pk": self.pk}) + + def get_image_urls(self, findings=False): + images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()] + + return ",".join(images) + + def get_image_url_array_not_json(self): + return self.get_image_url_array(json_output=False) + + def get_image_url_array(self, findings=False, json_output=True): + images = [f"{REMOTE_URL}{i.image.url}" for i in self.images.all()] + + if json_output: + return json.dumps(images) + else: + # This is a mess... + return format_html('", "'.join(images)) + + def get_thumbnail(self): + images = self.images.all() + + if len(images) < 1: + return "No images", 0 + + img = findMiddle(images).image + try: + thumbnailer = get_thumbnailer(img) + thumbnail = thumbnailer["exam-list"] + except InvalidImageFormatError: + return format_html('Invalid image url', img), len( + images + ) + return format_html('', thumbnail), len(images) + + def get_thumbnail_link(self): + return format_html( + "{}", self.get_absolute_url(), self.get_thumbnail()[0] + ) + + def get_block(self): + examination = self.get_examination_full() + thumb, image_number = self.get_thumbnail() + return format_html( + "
{}
{}
Images: {}
", examination, thumb, image_number + ) + + def order_by_upload_filename(self): + images = self.images.all() + + filenames = [] + map = {} + + for i in images: + filenames.append(i.upload_filename) + map[i.upload_filename] = i + + filenames = sorted(filenames) + + n = 1 + for f in filenames: + i = map[f] + i.position = n + i.save() + n = n + 1 + + def order_by_dicom(self, field="SliceLocation"): + images = self.images.all() + + files = [] + + for i in images: + files.append((i, pydicom.dcmread(i.image.path))) + + # print("file count: {}".format(len(files))) + + # skip files with no SliceLocation (eg scout views) + slices = [] + map = {} + skipcount = 0 + for i, f in files: + if hasattr(f, field): + slices.append(f) + # map[f.SliceLocation] = i + map[f[field].value] = i + else: + skipcount = skipcount + 1 + + print("skipped, no {}: {}".format(field, skipcount)) + + # ensure they are in the correct order + slices = sorted(slices, key=lambda s: s[field].value) + + # print(slices) + n = 1 + for f in slices: + i = map[f[field].value] + i.position = n + i.save() + n = n + 1 + + def get_total_image_size(self): + images = self.images.all() + + size = 0 + + for i in images: + size += i.image.size + + return size + + +class CaseCollection(models.Model): + name = models.CharField(max_length=255, unique=True) + + cases = models.ManyToManyField(Case, through="CaseDetail") + + open_access = models.BooleanField( + help_text="If a collection should be freely available to browse", default=True + ) + + def get_absolute_url(self): + return reverse("atlas:collection_detail_view", kwargs={"pk": self.pk}) + + def __str__(self) -> str: + return self.name + +class CaseDetail(models.Model): + case = models.ForeignKey(Case, on_delete=models.CASCADE) + collection = models.ForeignKey(CaseCollection, on_delete=models.CASCADE) + + sort_order = models.IntegerField() + + class Meta: + ordering = ("sort_order",) diff --git a/atlas/templates/atlas/case_display_block.html b/atlas/templates/atlas/case_display_block.html index 440c0974..f7d12e7b 100755 --- a/atlas/templates/atlas/case_display_block.html +++ b/atlas/templates/atlas/case_display_block.html @@ -1,125 +1,125 @@ -
-
- {{ case.created_date|date:"d/m/Y" }} -
-
- ID: {{ case.id }} -
-

Title: {{ case.title }}

- -

Description: {{ case.description }}

- -

- Presentation: -

    - {% for presentation in case.presentation.all %} -
  • {{presentation.get_link}}
  • - {% endfor %} -
-

- -

History: {{ case.history }}

- -

Discussion: {{ case.discussion }}

- -

Report: {{ case.report }}

- -
- {% comment %}

Scrapped: {{ case.scrapped }} (toggle) {% endcomment %} - -

-

- Condition: -

    - {% for con in case.condition.all %} -
  • {{con.get_link}}
  • - {% endfor %} -
-
- Findings - {% if case.series.all %} - See individual series for more details.
- {% for series in case.series.all %} - {% for finding in series.findings.all %} -
- - Series {{series.pk}}: {{series.get_examination_full}} |  - - {% if finding.findings.all %} - - Findings: {% for f in finding.findings.all %} - {{f.get_link}},  - {% endfor %} - - {% endif %} - {% if finding.structures.all %} - - Structure: {% for s in finding.structures.all %} - {{s.get_link}},  - {% endfor %} - - {% endif %} - {% if finding.description %} - - Description: {{finding.description}} - - {% endif %} -
- - {% endfor %} - {% endfor %} - {% else %} - No findings associated with case. - {% endif %} -
-
- Differentials -
    - {% for diff in case.differentialcase.all %} -
  • {{diff.condition.get_link}} - {{diff.text}}
  • - {% endfor %} -
-
-

-
- Subspecialty: -
    - {% for sub in case.subspecialty.all %} -
  • {{sub.get_link}}
  • - {% endfor %} -
- Pathological Process: -
    - {% for p in case.pathological_process.all %} -
  • {{p.get_link}}
  • - {% endfor %} -
-

-

Previous case: {{ case.previous_case }}

-

Next case: {{ case.next_case }}

-
- {% include 'question_notes.html' %} - -
- -

Checked by: {% for verified in case.verified.all %} {{verified}}, {% endfor %}

- -

Author(s): {% for author in case.author.all %} {{author}}, {% endfor %}

+
+
+ {{ case.created_date|date:"d/m/Y" }} +
+
+ ID: {{ case.id }} +
+

Title: {{ case.title }}

+ +

Description: {{ case.description }}

+ +

+ Presentation: +

    + {% for presentation in case.presentation.all %} +
  • {{presentation.get_link}}
  • + {% endfor %} +
+

+ +

History: {{ case.history }}

+ +

Discussion: {{ case.discussion }}

+ +

Report: {{ case.report }}

+ +
Series: + {% for series in case.series.all %} + + + Series {{ forloop.counter }}: + + {{series.get_block}} + + + Popup + + + {% endfor %} + Add new series
+
+ {% comment %}

Scrapped: {{ case.scrapped }} (toggle) {% endcomment %} + +

+

+ Condition: +

    + {% for con in case.condition.all %} +
  • {{con.get_link}}
  • + {% endfor %} +
+
+ Findings + {% if case.series.all %} + See individual series for more details.
+ {% for series in case.series.all %} + {% for finding in series.findings.all %} +
+ + Series {{series.pk}}: {{series.get_examination_full}} |  + + {% if finding.findings.all %} + + Findings: {% for f in finding.findings.all %} + {{f.get_link}},  + {% endfor %} + + {% endif %} + {% if finding.structures.all %} + + Structure: {% for s in finding.structures.all %} + {{s.get_link}},  + {% endfor %} + + {% endif %} + {% if finding.description %} + + Description: {{finding.description}} + + {% endif %} +
+ + {% endfor %} + {% endfor %} + {% else %} + No findings associated with case. + {% endif %} +
+
+ Differentials +
    + {% for diff in case.differentialcase.all %} +
  • {{diff.condition.get_link}} - {{diff.text}}
  • + {% endfor %} +
+
+

+
+ Subspecialty: +
    + {% for sub in case.subspecialty.all %} +
  • {{sub.get_link}}
  • + {% endfor %} +
+ Pathological Process: +
    + {% for p in case.pathological_process.all %} +
  • {{p.get_link}}
  • + {% endfor %} +
+

+

Previous case: {{ case.previous_case }}

+

Next case: {{ case.next_case }}

+
+ {% include 'question_notes.html' %} + +
+ +

Checked by: {% for verified in case.verified.all %} {{verified}}, {% endfor %}

+ +

Author(s): {% for author in case.author.all %} {{author}}, {% endfor %}

diff --git a/atlas/templates/atlas/collection_case_view.html b/atlas/templates/atlas/collection_case_view.html index 775fe520..a7c6342d 100644 --- a/atlas/templates/atlas/collection_case_view.html +++ b/atlas/templates/atlas/collection_case_view.html @@ -1,40 +1,52 @@ -{% extends 'atlas/base.html' %} - -{% block content %} -

Case {{case_number|add:1}}

- -
- {% for series in series_list %} - - - Series {{ forloop.counter }}: - - {{series.get_block}} - - - Popup - - - {% endfor %} -
- - {% for series in series_list %} -
-
-
- -
- - {% endfor %} -
-{% if previous %} - Previous -{% endif %} -{% if next %} - Next -{% endif %} -
-
-Return to collection -{% endblock %} \ No newline at end of file +{% extends 'atlas/base.html' %} + +{% block content %} +

Case {{case_number|add:1}}

+ +
+ {% for series in series_list %} + + + Series {{ forloop.counter }}: + + {{series.get_block}} + + + Popup + + + {% endfor %} +
+ +
+
+
+ +
+ +
+{% if previous %} + Previous +{% endif %} +{% if next %} + Next +{% endif %} +
+
+Return to collection +{% endblock %} +{% block js %} + + +{% endblock js %} \ No newline at end of file diff --git a/atlas/templates/atlas/collection_detail_view.html b/atlas/templates/atlas/collection_detail_view.html index 21d2427c..601fe1aa 100644 --- a/atlas/templates/atlas/collection_detail_view.html +++ b/atlas/templates/atlas/collection_detail_view.html @@ -1,12 +1,12 @@ -{% extends 'atlas/base.html' %} - -{% block content %} -

{{collection.name}} - +{% extends 'atlas/base.html' %} + +{% block content %} +

{{collection.name}} + {% endblock %} \ No newline at end of file diff --git a/atlas/templates/atlas/collection_index_view.html b/atlas/templates/atlas/collection_index_view.html index aeb87f34..8ca41105 100644 --- a/atlas/templates/atlas/collection_index_view.html +++ b/atlas/templates/atlas/collection_index_view.html @@ -1,9 +1,9 @@ -{% extends 'atlas/base.html' %} - -{% block content %} - +{% extends 'atlas/base.html' %} + +{% block content %} + {% endblock %} \ No newline at end of file diff --git a/atlas/templates/atlas/collection_series_viewer.html b/atlas/templates/atlas/collection_series_viewer.html index 4c4b8ebf..abe05350 100755 --- a/atlas/templates/atlas/collection_series_viewer.html +++ b/atlas/templates/atlas/collection_series_viewer.html @@ -1,172 +1,172 @@ -
{{ series.modality}}, {{ series.examination }}, {{ series.plane }}, {{ series.contrast }}
- - -
-
- -{% if editing_finding < 1 %} - -{% endif %} - -
-
-
- {% csrf_token %} - {{series_finding_form.management_form}} -
-
Description: {{ series_finding_form.description }}
-
Findings: {{ series_finding_form.findings }}
-
Structures: {{ series_finding_form.structures }}
-
- - -
-
-
- -
- Findings - - {% for finding in series.findings.all %} -
- - - Finding(s): {% for f in finding.findings.all %}{{f.get_link}}{% endfor %}
- Structure(s): {% for s in finding.structures.all %}{{s.get_link}}{% endfor %}
- Description: {{finding.description}}
-
- Edit - Delete -
- - {% endfor %} -
- - -
Author: {{ series.get_author_display }}
-
- Image info - - {% for image in series.images.all %} - {{image.image.url}}, pos: {{image.position}}, {{image.upload_filename}} [{{image.image.size|filesizeformat}}]
- {% comment %} {{image.get_dicom_info|safe}}
{% endcomment %} - - {% endfor %} - -

Total image size: {{series.get_total_image_size|filesizeformat}}

-

Download images

-
- - - \ No newline at end of file diff --git a/atlas/urls.py b/atlas/urls.py index de169b20..b7c6ba4f 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -1,151 +1,151 @@ -from django.urls import path, include - -from atlas.models import Condition, Finding, Presentation, Structure -from . import views - -app_name = "atlas" - -urlpatterns = [ - path("", views.CaseView.as_view(), name="index"), - path("author//", views.author_detail, name="author_detail"), - path("author/", views.author_list, name="author_list"), - path("case/", views.CaseView.as_view(), name="case_view"), - path("collection/", views.collection_index_view, name="collection_index_view"), - path("collections/", views.collection_detail_view, name="collection_detail_view"), - path("collections//", views.collection_case_view, name="collection_case_view"), - path("condition/", views.ConditionView.as_view(), name="condition_view"), - path("categories/", views.categories_list, name="categories_list"), - path("condition/", views.condition_detail, name="condition_detail"), - path( - "condition//delete", - views.ConditionDelete.as_view(), - name="condition_delete", - ), - path( - "condition//update", - views.ConditionUpdate.as_view(), - name="condition_update", - ), - path("subspecialty/", views.SubspecialtyView.as_view(), name="subspecialty_view"), - path("subspecialty/", views.subspecialty_detail, name="subspecialty_detail"), - path("condition/create", views.ConditionCreate.as_view(), name="condition_create"), - path("finding/", views.FindingView.as_view(), name="finding_view"), - path("finding/", views.finding_detail, name="finding_detail"), - path( - "finding//delete", views.FindingDelete.as_view(), name="finding_delete" - ), - path( - "finding//update", views.FindingUpdate.as_view(), name="finding_update" - ), - path("finding/create", views.FindingCreate.as_view(), name="finding_create"), - path("structure/", views.StructureView.as_view(), name="structure_view"), - path("structure/", views.structure_detail, name="structure_detail"), - path( - "structure//delete", - views.StructureDelete.as_view(), - name="structure_delete", - ), - path( - "structure//update", - views.StructureUpdate.as_view(), - name="structure_update", - ), - path("structure/create", views.StructureCreate.as_view(), name="structure_create"), - path("series/", views.series_detail, name="series_detail"), - path("series/", views.SeriesView.as_view(), name="series_view"), - path("series/", views.series_detail, name="series_detail"), - path( - "series//", - views.series_detail, - name="series_edit_finding", - ), - path( - "series//order_dicom", - views.series_order_dicom, - name="series_order_dicom", - ), - path( - "series//download", - views.SeriesImagesZipView.as_view(), - name="series_download", - ), - path( - "series//order_dicom_instance", - views.series_order_dicom_instance, - name="series_order_dicom_instance", - ), - path( - "series//order_dicom_SeriesInstanceUID", - views.series_order_dicom_SeriesInstanceUID, - name="series_order_dicom_SeriesInstanceUID", - ), - path( - "series//order_upload_filename", - views.series_order_upload_filename, - name="series_order_upload_filename", - ), - path( - "series//delete", - views.SeriesDelete.as_view(), - name="series_delete", - ), - # path("unchecked/", views.unchecked_list, name="unchecked_list"), - # path("verified//", views.verified_detail, name="verified_detail"), - path("case//", views.case_detail, name="case_detail"), - path("case//clone", views.AtlasClone.as_view(), name="case_clone"), - # path("verified/", views.verified, name="verified"), - # path("all_questions/", views.all_questions, name="all_questions"), - path("case//scrap", views.atlas_scrap, name="case_scrap"), - path("case//delete", views.CaseDelete.as_view(), name="case_delete"), - path("case/create/", views.AtlasCreate.as_view(), name="create"), - path("series/create", views.SeriesCreate.as_view(), name="series_create"), - path( - "series//create", - views.SeriesCreate.as_view(), - name="series_id_create", - ), - path( - "case//update", - views.AtlasUpdate.as_view(), - name="case_update", - ), - path( - "series//update", - views.SeriesUpdate.as_view(), - name="series_update", - ), - path( - "series/add_finding", - views.create_series_findings, - name="add_finding", - ), - path( - "series/delete_finding/", - views.SeriesFindingDelete.as_view(), - name="delete_finding", - ), - path( - "condition-autocomplete", - views.ConditionAutocomplete.as_view(model=Condition, create_field="name"), - name="condition-autocomplete", - ), - path( - "presentation-autocomplete", - views.PresentationAutocomplete.as_view(model=Presentation, create_field="name"), - name="presentation-autocomplete", - ), - path( - "finding-autocomplete", - views.FindingAutocomplete.as_view(model=Finding, create_field="name"), - name="finding-autocomplete", - ), - path( - "structure-autocomplete", - views.StructureAutocomplete.as_view(model=Structure, create_field="name"), - name="structure-autocomplete", - ), - path("presentation/", views.PresentationView.as_view(), name="presentation_view"), - path("presentation/", views.presentation_detail, name="presentation_detail"), - path("process/", views.PathologicalProcessView.as_view(), name="pathological_process_view"), - path("process/", views.pathological_process_detail, name="pathological_process_detail"), -] +from django.urls import path, include + +from atlas.models import Condition, Finding, Presentation, Structure +from . import views + +app_name = "atlas" + +urlpatterns = [ + path("", views.CaseView.as_view(), name="index"), + path("author//", views.author_detail, name="author_detail"), + path("author/", views.author_list, name="author_list"), + path("case/", views.CaseView.as_view(), name="case_view"), + path("collection/", views.collection_index_view, name="collection_index_view"), + path("collections/", views.collection_detail_view, name="collection_detail_view"), + path("collections//", views.collection_case_view, name="collection_case_view"), + path("condition/", views.ConditionView.as_view(), name="condition_view"), + path("categories/", views.categories_list, name="categories_list"), + path("condition/", views.condition_detail, name="condition_detail"), + path( + "condition//delete", + views.ConditionDelete.as_view(), + name="condition_delete", + ), + path( + "condition//update", + views.ConditionUpdate.as_view(), + name="condition_update", + ), + path("subspecialty/", views.SubspecialtyView.as_view(), name="subspecialty_view"), + path("subspecialty/", views.subspecialty_detail, name="subspecialty_detail"), + path("condition/create", views.ConditionCreate.as_view(), name="condition_create"), + path("finding/", views.FindingView.as_view(), name="finding_view"), + path("finding/", views.finding_detail, name="finding_detail"), + path( + "finding//delete", views.FindingDelete.as_view(), name="finding_delete" + ), + path( + "finding//update", views.FindingUpdate.as_view(), name="finding_update" + ), + path("finding/create", views.FindingCreate.as_view(), name="finding_create"), + path("structure/", views.StructureView.as_view(), name="structure_view"), + path("structure/", views.structure_detail, name="structure_detail"), + path( + "structure//delete", + views.StructureDelete.as_view(), + name="structure_delete", + ), + path( + "structure//update", + views.StructureUpdate.as_view(), + name="structure_update", + ), + path("structure/create", views.StructureCreate.as_view(), name="structure_create"), + path("series/", views.series_detail, name="series_detail"), + path("series/", views.SeriesView.as_view(), name="series_view"), + path("series/", views.series_detail, name="series_detail"), + path( + "series//", + views.series_detail, + name="series_edit_finding", + ), + path( + "series//order_dicom", + views.series_order_dicom, + name="series_order_dicom", + ), + path( + "series//download", + views.SeriesImagesZipView.as_view(), + name="series_download", + ), + path( + "series//order_dicom_instance", + views.series_order_dicom_instance, + name="series_order_dicom_instance", + ), + path( + "series//order_dicom_SeriesInstanceUID", + views.series_order_dicom_SeriesInstanceUID, + name="series_order_dicom_SeriesInstanceUID", + ), + path( + "series//order_upload_filename", + views.series_order_upload_filename, + name="series_order_upload_filename", + ), + path( + "series//delete", + views.SeriesDelete.as_view(), + name="series_delete", + ), + # path("unchecked/", views.unchecked_list, name="unchecked_list"), + # path("verified//", views.verified_detail, name="verified_detail"), + path("case//", views.case_detail, name="case_detail"), + path("case//clone", views.AtlasClone.as_view(), name="case_clone"), + # path("verified/", views.verified, name="verified"), + # path("all_questions/", views.all_questions, name="all_questions"), + path("case//scrap", views.atlas_scrap, name="case_scrap"), + path("case//delete", views.CaseDelete.as_view(), name="case_delete"), + path("case/create/", views.AtlasCreate.as_view(), name="create"), + path("series/create", views.SeriesCreate.as_view(), name="series_create"), + path( + "series//create", + views.SeriesCreate.as_view(), + name="series_id_create", + ), + path( + "case//update", + views.AtlasUpdate.as_view(), + name="case_update", + ), + path( + "series//update", + views.SeriesUpdate.as_view(), + name="series_update", + ), + path( + "series/add_finding", + views.create_series_findings, + name="add_finding", + ), + path( + "series/delete_finding/", + views.SeriesFindingDelete.as_view(), + name="delete_finding", + ), + path( + "condition-autocomplete", + views.ConditionAutocomplete.as_view(model=Condition, create_field="name"), + name="condition-autocomplete", + ), + path( + "presentation-autocomplete", + views.PresentationAutocomplete.as_view(model=Presentation, create_field="name"), + name="presentation-autocomplete", + ), + path( + "finding-autocomplete", + views.FindingAutocomplete.as_view(model=Finding, create_field="name"), + name="finding-autocomplete", + ), + path( + "structure-autocomplete", + views.StructureAutocomplete.as_view(model=Structure, create_field="name"), + name="structure-autocomplete", + ), + path("presentation/", views.PresentationView.as_view(), name="presentation_view"), + path("presentation/", views.presentation_detail, name="presentation_detail"), + path("process/", views.PathologicalProcessView.as_view(), name="pathological_process_view"), + path("process/", views.pathological_process_detail, name="pathological_process_detail"), +] diff --git a/atlas/views.py b/atlas/views.py index 40e6f934..c163dd18 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -1,919 +1,919 @@ -import json -from dal import autocomplete -from django.shortcuts import render, get_object_or_404, redirect -from django import forms - -# from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required, user_passes_test -from django.contrib.auth.models import User -from django.core.exceptions import ( - FieldError, - ObjectDoesNotExist, - PermissionDenied, - ViewDoesNotExist, -) - -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin -from django.views.generic.detail import DetailView -from generic.mixins import SuperuserRequiredMixin - -from django.views.generic.edit import CreateView, UpdateView, DeleteView -from django.views.generic import ListView -from django.views.decorators.csrf import csrf_exempt - -from django.urls import reverse_lazy, reverse - -from django.http import Http404, JsonResponse -from django.http import HttpResponseRedirect, HttpResponse - -from .forms import ( - CaseForm, - ConditionForm, - FindingForm, - SeriesForm, - SeriesImageFormSet, - SeriesFormSet, - SeriesFindingForm, - CaseDifferentialFormSet, - StructureForm, -) -from .models import ( - Case, - CaseCollection, - Condition, - PathologicalProcess, - Presentation, - Series, - Examination, - Finding, - Structure, - Subspecialty, - SeriesFinding, - SeriesImage, -) -from .tables import ( - CaseTable, - ConditionTable, - FindingTable, - PathologicalProcessTable, - PresentationTable, - SeriesTable, - StructureTable, - SubspecialtyTable, -) -from .filters import ( - CaseFilter, - ConditionFilter, - FindingFilter, - PathologicalProcessFilter, - PresentationFilter, - SeriesFilter, - StructureFilter, - SubspecialtyFilter, -) - -from django_tables2 import SingleTableView, SingleTableMixin -from django_filters.views import FilterView - -from .decorators import ( - user_is_author_or_atlas_editor, - user_is_author_or_atlas_editor_or_atlas_marker, - user_is_author_or_atlas_series_checker_or_atlas_marker, - user_is_atlas_editor, - user_is_author_or_atlas_series_checker, - user_is_atlas_marker, - user_is_author_or_atlas_editor_or_atlas_marker, -) - -from collections import defaultdict -import json -import statistics -import plotly.express as px - -from django.core.cache import cache -from django.conf import settings - -from helpers.images import image_as_base64 - -import logging -from copy import deepcopy -from django.forms.models import model_to_dict - -from generic.views import ExamCloneMixin, ExamViews -from reversion.views import RevisionMixin -import reversion - - -from rest_framework import viewsets - -from zipview.views import BaseZipView - -import os - -logger = logging.getLogger(__name__) - - -class AuthorOrCheckerRequiredMixin(object): - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if ( - self.request.user.groups.filter(name="atlas_editor").exists() - or self.request.user.is_superuser - ): - return obj - if self.request.user not in obj.get_author_objects(): - raise PermissionDenied() # or Http404 - return obj - - -@login_required -@user_is_author_or_atlas_editor_or_atlas_marker -def case_detail(request, pk): - case = get_object_or_404(Case, pk=pk) - - # if request.user not in atlas.author.all(): - # raise PermissionDenied - - # logging.debug(atlas.subspecialty.first().name.all()) - return render(request, "atlas/case_detail.html", {"case": case}) - - -@login_required -@user_is_author_or_atlas_series_checker_or_atlas_marker -def series_detail(request, pk, finding_pk=None): - series = get_object_or_404(Series, pk=pk) - - editing_finding = -1 - if finding_pk is not None: - finding = get_object_or_404(SeriesFinding, pk=finding_pk) - series_finding_form = SeriesFindingForm(None, instance=finding) - editing_finding = finding.pk - else: - series_finding_form = SeriesFindingForm(series_id=series.id) - - # logging.debug(atlas.subspecialty.first().name.all()) - return render( - request, - "atlas/series.html", - { - "series": series, - "series_finding_form": series_finding_form, - "editing_finding": editing_finding, - }, - ) - - -@login_required -@user_is_atlas_editor -def condition_detail(request, pk): - condition = get_object_or_404(Condition, pk=pk) - - # logging.debug(atlas.subspecialty.first().name.all()) - return render( - request, - "atlas/condition_detail.html", - { - "condition": condition, - }, - ) - - -@login_required -@user_is_atlas_editor -def subspecialty_detail(request, pk): - subspecialty = get_object_or_404(Subspecialty, pk=pk) - - # logging.debug(atlas.subspecialty.first().name.all()) - return render( - request, - "atlas/subspecialty_detail.html", - { - "subspecialty": subspecialty, - }, - ) - - -@login_required -@user_is_atlas_editor -def presentation_detail(request, pk): - presentation = get_object_or_404(Presentation, pk=pk) - - # logging.debug(atlas.subspecialty.first().name.all()) - return render( - request, - "atlas/presentation_detail.html", - { - "presentation": presentation, - }, - ) - - -@login_required -@user_is_atlas_editor -def pathological_process_detail(request, pk): - pathological_process = get_object_or_404(PathologicalProcess, pk=pk) - - # logging.debug(atlas.subspecialty.first().name.all()) - return render( - request, - "atlas/pathological_process_detail.html", - { - "pathological_process": pathological_process, - }, - ) - - -@login_required -@user_is_atlas_editor -def structure_detail(request, pk): - structure = get_object_or_404(Structure, pk=pk) - - # logging.debug(atlas.subspecialty.first().name.all()) - return render( - request, - "atlas/structure_detail.html", - { - "structure": structure, - }, - ) - - -@login_required -@user_is_atlas_editor -def finding_detail(request, pk): - finding = get_object_or_404(Finding, pk=pk) - - # logging.debug(atlas.subspecialty.first().name.all()) - return render( - request, - "atlas/finding_detail.html", - { - "finding": finding, - }, - ) - - -@login_required -@user_is_author_or_atlas_editor -def author_detail(request, pk): - # logging.debug(Author.objects.all()) - # author = get_object_or_404(Author, pk=pk) - author = User.objects.get(pk=pk) - - atlas = Case.objects.filter(author=pk) - - return render( - request, "atlas/category_detail.html", {"category": author, "atlas": atlas} - ) - - -@user_is_author_or_atlas_editor -def author_list(request): - authors = User.objects.all() - - return render(request, "atlas/author_list.html", {"authors": authors}) - - -class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Case - success_url = reverse_lazy("atlas:case_view") - template_name = "confirm_delete.html" - - -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_success_url(self): - pk = self.kwargs["pk"] - series_id = get_object_or_404(SeriesFinding, pk=pk).series.id - return reverse("atlas:series_detail", kwargs={"pk": series_id}) - - -class SeriesDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Series - template_name = "confirm_delete.html" - success_url = reverse_lazy("atlas:series_view") - - -class ConditionDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Condition - template_name = "confirm_delete.html" - success_url = reverse_lazy("atlas:condition_view") - - -class FindingDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Finding - template_name = "confirm_delete.html" - success_url = reverse_lazy("atlas:finding_view") - - -class StructureDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Structure - template_name = "confirm_delete.html" - success_url = reverse_lazy("atlas:structure_view") - - -@login_required -@user_is_author_or_atlas_editor -def case_clone(request, pk): - new_item = get_object_or_404(Case, pk=pk) - new_item.pk = None # autogen a new pk (item_id) - # new_item.name = "Copy of " + new_item.name #need to change uniques - - form = CaseForm(request.POST or None, instance=new_item) - - series_formset = SeriesFormSet() - - if form.is_valid(): - form.instance.author.add(request.user.id) - - # logger.debug(formset.is_valid()) - if series_formset.is_valid(): - response = super().form_valid(form) - series_formset.instance = obj - series_formset.save() - - return response - else: - return super().form_invalid(form) - - context = { - "form": form, - "series_formset": series_formset, - # other context - } - - return render(request, "atlas/case_form.html", context) - - -class SeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = Series - form_class = SeriesForm - - def get_initial(self): - # print(self.request) - if "pk" in self.kwargs: - initial = super().get_initial() - case = get_object_or_404(Case, pk=self.kwargs["pk"]) - - initial["case"] = case.id - - return initial - - def get_context_data(self, **kwargs): - context = super(SeriesCreate, self).get_context_data(**kwargs) - - if self.request.POST: - context["image_formset"] = SeriesImageFormSet( - self.request.POST, self.request.FILES - ) - context["image_formset"].full_clean() - else: - context["image_formset"] = SeriesImageFormSet() - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - series_formset = context["image_formset"] - if series_formset.is_valid(): - response = super().form_valid(form) - series_formset.instance = self.object - series_formset.save() - return response - - else: - return super().form_invalid(form) - - -class SeriesUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Series - form_class = SeriesForm - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - def get_context_data(self, **kwargs): - context = super(SeriesUpdate, self).get_context_data(**kwargs) - if self.request.POST: - context["image_formset"] = SeriesImageFormSet( - self.request.POST, self.request.FILES, instance=self.object - ) - - context["image_formset"].full_clean() - else: - context["image_formset"] = SeriesImageFormSet(instance=self.object) - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - image_formset = context["image_formset"] - # logger.debug(formset.is_valid()) - if image_formset.is_valid(): - response = super().form_valid(form) - image_formset.instance = self.object - image_formset.save() - return response - else: - return super().form_invalid(form) - - -class ConditionUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Condition - form_class = ConditionForm - - -class ConditionCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = Condition - form_class = ConditionForm - - -class FindingUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Finding - form_class = FindingForm - - -class FindingCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = Finding - form_class = FindingForm - - -class StructureUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Structure - form_class = StructureForm - - -class StructureCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = Structure - form_class = StructureForm - - -class AtlasCreateBase(RevisionMixin, LoginRequiredMixin): - model = Case - form_class = CaseForm - - def get_form_kwargs(self): - kwargs = super(AtlasCreateBase, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(AtlasCreateBase, self).get_context_data(**kwargs) - if self.request.POST: - context["series_formset"] = SeriesFormSet( - self.request.POST, self.request.FILES - ) - context["casedifferential_formset"] = CaseDifferentialFormSet( - self.request.POST, self.request.FILES - ) - context["series_formset"].full_clean() - context["casedifferential_formset"].full_clean() - else: - context["series_formset"] = SeriesFormSet() - context["casedifferential_formset"] = CaseDifferentialFormSet() - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - - # Add exam objects - - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - series_formset = context["series_formset"] - casedifferential_formset = context["casedifferential_formset"] - if series_formset.is_valid() and casedifferential_formset.is_valid(): - response = super().form_valid(form) - series_formset.instance = self.object - series_formset.save() - casedifferential_formset.instance = self.object - casedifferential_formset.save() - # If the normal submit button is pressed we save as normal - if "submit" in self.request.POST: - return response - # else we redirect to the clone url - else: - return redirect("atlas:case_clone", pk=self.object.pk) - - else: - return super().form_invalid(form) - - -# @login_required -class AtlasCreate(AtlasCreateBase, CreateView): - def get_initial(self): - # There has to be a better way... - try: - s = (i.pk for i in self.request.user.atlas_default.site.all()) - self.initial.update({"site": s}) - except AttributeError: - pass - return self.initial - - -class AtlasUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Case - form_class = CaseForm - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - - def get_form_kwargs(self): - kwargs = super(AtlasUpdate, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(AtlasUpdate, self).get_context_data(**kwargs) - if self.request.POST: - context["series_formset"] = SeriesFormSet( - self.request.POST, self.request.FILES, instance=self.object - ) - context["casedifferential_formset"] = CaseDifferentialFormSet( - self.request.POST, self.request.FILES, instance=self.object - ) - context["series_formset"].full_clean() - context["casedifferential_formset"].full_clean() - else: - context["series_formset"] = SeriesFormSet(instance=self.object) - context["casedifferential_formset"] = CaseDifferentialFormSet( - instance=self.object - ) - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - series_formset = context["series_formset"] - casedifferential_formset = context["casedifferential_formset"] - # logger.debug(formset.is_valid()) - if series_formset.is_valid() and casedifferential_formset.is_valid(): - response = super().form_valid(form) - series_formset.instance = self.object - series_formset.save() - casedifferential_formset.instance = self.object - casedifferential_formset.save() - - return response - else: - return super().form_invalid(form) - - -class AtlasClone(AtlasCreateBase, CreateView): - """Clones a existing atlas""" - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - def get_initial(self): - # print(self.request) - old_object = get_object_or_404(Case, pk=self.kwargs["pk"]) - initial_data = model_to_dict(old_object, exclude=["id"]) - - return initial_data - - -@login_required -@user_is_author_or_atlas_editor -def atlas_scrap(request, pk): - try: - atlas = Case.objects.get(pk=pk) - except Case.DoesNotExist: - raise Http404("Case does not exist") - - atlas.scrapped = not atlas.scrapped - atlas.save() - return HttpResponseRedirect(reverse("atlas:case_detail", args=(pk,))) - - -# @login_required -# def edit_abnormality_popup(request): -# instance = get_object_or_404(Abnormality, pk = pk) -# form = AbnormalityForm(request.POST or None) -# if form.is_valid(): -# instance = form.save() -# return HttpResponse('' % (instance.pk, instance)) -# return render(request, "atlas/create_simple.html", {'form': form}) - - -@csrf_exempt -def get_examination_id(request): - if request.is_ajax(): - examination_name = request.GET["examination_name"] - examination_id = Examination.objects.get(name=examination_name).id - data = { - "examination_id": examination_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Case - table_class = CaseTable - template_name = "atlas/view.html" - - filterset_class = CaseFilter - - -class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Series - table_class = SeriesTable - template_name = "atlas/view.html" - - filterset_class = SeriesFilter - - -class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Condition - table_class = ConditionTable - template_name = "atlas/view.html" - - filterset_class = ConditionFilter - - -class SubspecialtyView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Subspecialty - table_class = SubspecialtyTable - template_name = "atlas/view.html" - - filterset_class = SubspecialtyFilter - - -class PresentationView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Presentation - table_class = PresentationTable - template_name = "atlas/view.html" - - filterset_class = PresentationFilter - - -class PathologicalProcessView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = PathologicalProcess - table_class = PathologicalProcessTable - template_name = "atlas/view.html" - - filterset_class = PathologicalProcessFilter - - -class StructureView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Structure - table_class = StructureTable - template_name = "atlas/view.html" - - filterset_class = StructureFilter - - -class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Finding - table_class = FindingTable - template_name = "atlas/view.html" - - filterset_class = FindingFilter - - -@login_required -@user_is_author_or_atlas_series_checker -def series_order_dicom(request, pk): - series = get_object_or_404(Series, pk=pk) - try: - series.order_by_dicom() - except: - return HttpResponse("

Series does not appear to contain dicoms

") - - return redirect("atlas:series_detail", pk=pk) - - -@login_required -@user_is_author_or_atlas_series_checker -def series_order_dicom_instance(request, pk): - series = get_object_or_404(Series, pk=pk) - try: - series.order_by_dicom("InstanceNumber") - except: - return HttpResponse( - "

Series does not appear to contain dicoms (or field InstanceNumber)

" - ) - - return redirect("atlas:series_detail", pk=pk) - - -@login_required -@user_is_author_or_atlas_series_checker -def series_order_dicom_SeriesInstanceUID(request, pk): - series = get_object_or_404(Series, pk=pk) - try: - series.order_by_dicom("SeriesInstanceUID") - except: - return HttpResponse( - "

Series does not appear to contain dicoms (or field SeriesInstanceUID)

" - ) - - return redirect("atlas:series_detail", pk=pk) - - -@login_required -@user_is_author_or_atlas_series_checker -def series_order_upload_filename(request, pk): - series = get_object_or_404(Series, pk=pk) - series.order_by_upload_filename() - - return redirect("atlas:series_detail", pk=pk) - - -class SeriesImagesZipView(SuperuserRequiredMixin, BaseZipView): - """Download all images from an image series""" - - def get_files(self): - series = Series.objects.get(pk=self.kwargs["pk"]) - - return [i.image.file for i in series.images.all()] - - -def create_series_findings(request): - # posts = Post.objects.all() - response_data = {} - - if request.POST.get("action") == "post": - series_finding_id = int(request.POST.get("series_finding_id")) - series_id = request.POST.get("series") - findings_ids = json.loads(request.POST.get("findings")) - structure_ids = json.loads(request.POST.get("structures")) - description = request.POST.get("description") - annotation_json = request.POST.get("annotation_json") - viewport_json = request.POST.get("viewport_json") - - series = Series.objects.get(pk=series_id) - findings = Finding.objects.filter(pk__in=findings_ids) - structures = Structure.objects.filter(pk__in=structure_ids) - - if series_finding_id > 0: - sf = get_object_or_404(SeriesFinding, pk=series_finding_id) - sf.series = series - sf.description = description - sf.annotation_json = annotation_json - sf.viewport_json = viewport_json - else: - sf = SeriesFinding.objects.create( - series=series, - description=description, - annotation_json=annotation_json, - viewport_json=viewport_json, - ) - - sf.findings.set(findings) - sf.structures.set(structures) - sf.save() - return JsonResponse({"success": True}) - - return JsonResponse({"success": False}) - return render(request, "create_post.html", {"posts": posts}) - - -class ConditionAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - # Don't forget to filter out results depending on the visitor ! - if not self.request.user.is_authenticated: - return Condition.objects.none() - - qs = Condition.objects.all() - - if self.q: - # This raises a fielderror which breaks creating a new item if not caught - try: - qs = qs.filter(name__icontains=self.q) - except FieldError: - return Condition.objects.none() - - return qs - - -class PresentationAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - # Don't forget to filter out results depending on the visitor ! - if not self.request.user.is_authenticated: - return Presentation.objects.none() - - qs = Presentation.objects.all() - - if self.q: - # This raises a fielderror which breaks creating a new item if not caught - try: - qs = qs.filter(name__icontains=self.q) - except FieldError: - return Presentation.objects.none() - - return qs - - -class FindingAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - # Don't forget to filter out results depending on the visitor ! - if not self.request.user.is_authenticated: - return Finding.objects.none() - - qs = Finding.objects.all() - - if self.q: - # This raises a fielderror which breaks creating a new item if not caught - try: - qs = qs.filter(name__icontains=self.q) - except FieldError: - return Finding.objects.none() - - return qs - - -class StructureAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - # Don't forget to filter out results depending on the visitor ! - if not self.request.user.is_authenticated: - return Structure.objects.none() - - qs = Structure.objects.all() - - if self.q: - # This raises a fielderror which breaks creating a new item if not caught - try: - qs = qs.filter(name__icontains=self.q) - except FieldError: - return Structure.objects.none() - - return qs - - -@login_required -@user_is_atlas_editor -def categories_list(request): - # condition = get_object_or_404(Condition, pk=pk) - - # logging.debug(atlas.subspecialty.first().name.all()) - return render( - request, - "atlas/categories_list.html", - # { - # "condition": condition, - # }, - ) - - -def collection_index_view(request): - collections = CaseCollection.objects.all() - - return render(request, "atlas/collection_index_view.html", {"collections": collections}) - -def collection_detail_view(request, pk): - collection = get_object_or_404(CaseCollection, pk=pk) - - return render(request, "atlas/collection_detail_view.html", {"collection": collection}) - -def collection_case_view(request, pk, case_number): - collection = get_object_or_404(CaseCollection, pk=pk) - - cases = collection.cases.all().prefetch_related() - case = cases[case_number] - - series_list = case.series.all().prefetch_related("images", "examination", "plane") - - previous = case_number > 0 - next = case_number < (len(cases) - 1) - - +import json +from dal import autocomplete +from django.shortcuts import render, get_object_or_404, redirect +from django import forms + +# from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User +from django.core.exceptions import ( + FieldError, + ObjectDoesNotExist, + PermissionDenied, + ViewDoesNotExist, +) + +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.views.generic.detail import DetailView +from generic.mixins import SuperuserRequiredMixin + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView +from django.views.decorators.csrf import csrf_exempt + +from django.urls import reverse_lazy, reverse + +from django.http import Http404, JsonResponse +from django.http import HttpResponseRedirect, HttpResponse + +from .forms import ( + CaseForm, + ConditionForm, + FindingForm, + SeriesForm, + SeriesImageFormSet, + SeriesFormSet, + SeriesFindingForm, + CaseDifferentialFormSet, + StructureForm, +) +from .models import ( + Case, + CaseCollection, + Condition, + PathologicalProcess, + Presentation, + Series, + Examination, + Finding, + Structure, + Subspecialty, + SeriesFinding, + SeriesImage, +) +from .tables import ( + CaseTable, + ConditionTable, + FindingTable, + PathologicalProcessTable, + PresentationTable, + SeriesTable, + StructureTable, + SubspecialtyTable, +) +from .filters import ( + CaseFilter, + ConditionFilter, + FindingFilter, + PathologicalProcessFilter, + PresentationFilter, + SeriesFilter, + StructureFilter, + SubspecialtyFilter, +) + +from django_tables2 import SingleTableView, SingleTableMixin +from django_filters.views import FilterView + +from .decorators import ( + user_is_author_or_atlas_editor, + user_is_author_or_atlas_editor_or_atlas_marker, + user_is_author_or_atlas_series_checker_or_atlas_marker, + user_is_atlas_editor, + user_is_author_or_atlas_series_checker, + user_is_atlas_marker, + user_is_author_or_atlas_editor_or_atlas_marker, +) + +from collections import defaultdict +import json +import statistics +import plotly.express as px + +from django.core.cache import cache +from django.conf import settings + +from helpers.images import image_as_base64 + +import logging +from copy import deepcopy +from django.forms.models import model_to_dict + +from generic.views import ExamCloneMixin, ExamViews +from reversion.views import RevisionMixin +import reversion + + +from rest_framework import viewsets + +from zipview.views import BaseZipView + +import os + +logger = logging.getLogger(__name__) + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if ( + self.request.user.groups.filter(name="atlas_editor").exists() + or self.request.user.is_superuser + ): + return obj + if self.request.user not in obj.get_author_objects(): + raise PermissionDenied() # or Http404 + return obj + + +@login_required +@user_is_author_or_atlas_editor_or_atlas_marker +def case_detail(request, pk): + case = get_object_or_404(Case, pk=pk) + + # if request.user not in atlas.author.all(): + # raise PermissionDenied + + # logging.debug(atlas.subspecialty.first().name.all()) + return render(request, "atlas/case_detail.html", {"case": case}) + + +@login_required +@user_is_author_or_atlas_series_checker_or_atlas_marker +def series_detail(request, pk, finding_pk=None): + series = get_object_or_404(Series, pk=pk) + + editing_finding = -1 + if finding_pk is not None: + finding = get_object_or_404(SeriesFinding, pk=finding_pk) + series_finding_form = SeriesFindingForm(None, instance=finding) + editing_finding = finding.pk + else: + series_finding_form = SeriesFindingForm(series_id=series.id) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "atlas/series.html", + { + "series": series, + "series_finding_form": series_finding_form, + "editing_finding": editing_finding, + }, + ) + + +@login_required +@user_is_atlas_editor +def condition_detail(request, pk): + condition = get_object_or_404(Condition, pk=pk) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "atlas/condition_detail.html", + { + "condition": condition, + }, + ) + + +@login_required +@user_is_atlas_editor +def subspecialty_detail(request, pk): + subspecialty = get_object_or_404(Subspecialty, pk=pk) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "atlas/subspecialty_detail.html", + { + "subspecialty": subspecialty, + }, + ) + + +@login_required +@user_is_atlas_editor +def presentation_detail(request, pk): + presentation = get_object_or_404(Presentation, pk=pk) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "atlas/presentation_detail.html", + { + "presentation": presentation, + }, + ) + + +@login_required +@user_is_atlas_editor +def pathological_process_detail(request, pk): + pathological_process = get_object_or_404(PathologicalProcess, pk=pk) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "atlas/pathological_process_detail.html", + { + "pathological_process": pathological_process, + }, + ) + + +@login_required +@user_is_atlas_editor +def structure_detail(request, pk): + structure = get_object_or_404(Structure, pk=pk) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "atlas/structure_detail.html", + { + "structure": structure, + }, + ) + + +@login_required +@user_is_atlas_editor +def finding_detail(request, pk): + finding = get_object_or_404(Finding, pk=pk) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "atlas/finding_detail.html", + { + "finding": finding, + }, + ) + + +@login_required +@user_is_author_or_atlas_editor +def author_detail(request, pk): + # logging.debug(Author.objects.all()) + # author = get_object_or_404(Author, pk=pk) + author = User.objects.get(pk=pk) + + atlas = Case.objects.filter(author=pk) + + return render( + request, "atlas/category_detail.html", {"category": author, "atlas": atlas} + ) + + +@user_is_author_or_atlas_editor +def author_list(request): + authors = User.objects.all() + + return render(request, "atlas/author_list.html", {"authors": authors}) + + +class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Case + success_url = reverse_lazy("atlas:case_view") + template_name = "confirm_delete.html" + + +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_success_url(self): + pk = self.kwargs["pk"] + series_id = get_object_or_404(SeriesFinding, pk=pk).series.id + return reverse("atlas:series_detail", kwargs={"pk": series_id}) + + +class SeriesDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Series + template_name = "confirm_delete.html" + success_url = reverse_lazy("atlas:series_view") + + +class ConditionDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Condition + template_name = "confirm_delete.html" + success_url = reverse_lazy("atlas:condition_view") + + +class FindingDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Finding + template_name = "confirm_delete.html" + success_url = reverse_lazy("atlas:finding_view") + + +class StructureDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Structure + template_name = "confirm_delete.html" + success_url = reverse_lazy("atlas:structure_view") + + +@login_required +@user_is_author_or_atlas_editor +def case_clone(request, pk): + new_item = get_object_or_404(Case, pk=pk) + new_item.pk = None # autogen a new pk (item_id) + # new_item.name = "Copy of " + new_item.name #need to change uniques + + form = CaseForm(request.POST or None, instance=new_item) + + series_formset = SeriesFormSet() + + if form.is_valid(): + form.instance.author.add(request.user.id) + + # logger.debug(formset.is_valid()) + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = obj + series_formset.save() + + return response + else: + return super().form_invalid(form) + + context = { + "form": form, + "series_formset": series_formset, + # other context + } + + return render(request, "atlas/case_form.html", context) + + +class SeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Series + form_class = SeriesForm + + def get_initial(self): + # print(self.request) + if "pk" in self.kwargs: + initial = super().get_initial() + case = get_object_or_404(Case, pk=self.kwargs["pk"]) + + initial["case"] = case.id + + return initial + + def get_context_data(self, **kwargs): + context = super(SeriesCreate, self).get_context_data(**kwargs) + + if self.request.POST: + context["image_formset"] = SeriesImageFormSet( + self.request.POST, self.request.FILES + ) + context["image_formset"].full_clean() + else: + context["image_formset"] = SeriesImageFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["image_formset"] + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + return response + + else: + return super().form_invalid(form) + + +class SeriesUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Series + form_class = SeriesForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_context_data(self, **kwargs): + context = super(SeriesUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["image_formset"] = SeriesImageFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + + context["image_formset"].full_clean() + else: + context["image_formset"] = SeriesImageFormSet(instance=self.object) + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + image_formset = context["image_formset"] + # logger.debug(formset.is_valid()) + if image_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_formset.save() + return response + else: + return super().form_invalid(form) + + +class ConditionUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Condition + form_class = ConditionForm + + +class ConditionCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Condition + form_class = ConditionForm + + +class FindingUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Finding + form_class = FindingForm + + +class FindingCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Finding + form_class = FindingForm + + +class StructureUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Structure + form_class = StructureForm + + +class StructureCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Structure + form_class = StructureForm + + +class AtlasCreateBase(RevisionMixin, LoginRequiredMixin): + model = Case + form_class = CaseForm + + def get_form_kwargs(self): + kwargs = super(AtlasCreateBase, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(AtlasCreateBase, self).get_context_data(**kwargs) + if self.request.POST: + context["series_formset"] = SeriesFormSet( + self.request.POST, self.request.FILES + ) + context["casedifferential_formset"] = CaseDifferentialFormSet( + self.request.POST, self.request.FILES + ) + context["series_formset"].full_clean() + context["casedifferential_formset"].full_clean() + else: + context["series_formset"] = SeriesFormSet() + context["casedifferential_formset"] = CaseDifferentialFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + + # Add exam objects + + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["series_formset"] + casedifferential_formset = context["casedifferential_formset"] + if series_formset.is_valid() and casedifferential_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + casedifferential_formset.instance = self.object + casedifferential_formset.save() + # If the normal submit button is pressed we save as normal + if "submit" in self.request.POST: + return response + # else we redirect to the clone url + else: + return redirect("atlas:case_clone", pk=self.object.pk) + + else: + return super().form_invalid(form) + + +# @login_required +class AtlasCreate(AtlasCreateBase, CreateView): + def get_initial(self): + # There has to be a better way... + try: + s = (i.pk for i in self.request.user.atlas_default.site.all()) + self.initial.update({"site": s}) + except AttributeError: + pass + return self.initial + + +class AtlasUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Case + form_class = CaseForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + + def get_form_kwargs(self): + kwargs = super(AtlasUpdate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(AtlasUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["series_formset"] = SeriesFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["casedifferential_formset"] = CaseDifferentialFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["series_formset"].full_clean() + context["casedifferential_formset"].full_clean() + else: + context["series_formset"] = SeriesFormSet(instance=self.object) + context["casedifferential_formset"] = CaseDifferentialFormSet( + instance=self.object + ) + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["series_formset"] + casedifferential_formset = context["casedifferential_formset"] + # logger.debug(formset.is_valid()) + if series_formset.is_valid() and casedifferential_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + casedifferential_formset.instance = self.object + casedifferential_formset.save() + + return response + else: + return super().form_invalid(form) + + +class AtlasClone(AtlasCreateBase, CreateView): + """Clones a existing atlas""" + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_initial(self): + # print(self.request) + old_object = get_object_or_404(Case, pk=self.kwargs["pk"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + return initial_data + + +@login_required +@user_is_author_or_atlas_editor +def atlas_scrap(request, pk): + try: + atlas = Case.objects.get(pk=pk) + except Case.DoesNotExist: + raise Http404("Case does not exist") + + atlas.scrapped = not atlas.scrapped + atlas.save() + return HttpResponseRedirect(reverse("atlas:case_detail", args=(pk,))) + + +# @login_required +# def edit_abnormality_popup(request): +# instance = get_object_or_404(Abnormality, pk = pk) +# form = AbnormalityForm(request.POST or None) +# if form.is_valid(): +# instance = form.save() +# return HttpResponse('' % (instance.pk, instance)) +# return render(request, "atlas/create_simple.html", {'form': form}) + + +@csrf_exempt +def get_examination_id(request): + if request.is_ajax(): + examination_name = request.GET["examination_name"] + examination_id = Examination.objects.get(name=examination_name).id + data = { + "examination_id": examination_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +class CaseView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Case + table_class = CaseTable + template_name = "atlas/view.html" + + filterset_class = CaseFilter + + +class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Series + table_class = SeriesTable + template_name = "atlas/view.html" + + filterset_class = SeriesFilter + + +class ConditionView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Condition + table_class = ConditionTable + template_name = "atlas/view.html" + + filterset_class = ConditionFilter + + +class SubspecialtyView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Subspecialty + table_class = SubspecialtyTable + template_name = "atlas/view.html" + + filterset_class = SubspecialtyFilter + + +class PresentationView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Presentation + table_class = PresentationTable + template_name = "atlas/view.html" + + filterset_class = PresentationFilter + + +class PathologicalProcessView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = PathologicalProcess + table_class = PathologicalProcessTable + template_name = "atlas/view.html" + + filterset_class = PathologicalProcessFilter + + +class StructureView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Structure + table_class = StructureTable + template_name = "atlas/view.html" + + filterset_class = StructureFilter + + +class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Finding + table_class = FindingTable + template_name = "atlas/view.html" + + filterset_class = FindingFilter + + +@login_required +@user_is_author_or_atlas_series_checker +def series_order_dicom(request, pk): + series = get_object_or_404(Series, pk=pk) + try: + series.order_by_dicom() + except: + return HttpResponse("

Series does not appear to contain dicoms

") + + return redirect("atlas:series_detail", pk=pk) + + +@login_required +@user_is_author_or_atlas_series_checker +def series_order_dicom_instance(request, pk): + series = get_object_or_404(Series, pk=pk) + try: + series.order_by_dicom("InstanceNumber") + except: + return HttpResponse( + "

Series does not appear to contain dicoms (or field InstanceNumber)

" + ) + + return redirect("atlas:series_detail", pk=pk) + + +@login_required +@user_is_author_or_atlas_series_checker +def series_order_dicom_SeriesInstanceUID(request, pk): + series = get_object_or_404(Series, pk=pk) + try: + series.order_by_dicom("SeriesInstanceUID") + except: + return HttpResponse( + "

Series does not appear to contain dicoms (or field SeriesInstanceUID)

" + ) + + return redirect("atlas:series_detail", pk=pk) + + +@login_required +@user_is_author_or_atlas_series_checker +def series_order_upload_filename(request, pk): + series = get_object_or_404(Series, pk=pk) + series.order_by_upload_filename() + + return redirect("atlas:series_detail", pk=pk) + + +class SeriesImagesZipView(SuperuserRequiredMixin, BaseZipView): + """Download all images from an image series""" + + def get_files(self): + series = Series.objects.get(pk=self.kwargs["pk"]) + + return [i.image.file for i in series.images.all()] + + +def create_series_findings(request): + # posts = Post.objects.all() + response_data = {} + + if request.POST.get("action") == "post": + series_finding_id = int(request.POST.get("series_finding_id")) + series_id = request.POST.get("series") + findings_ids = json.loads(request.POST.get("findings")) + structure_ids = json.loads(request.POST.get("structures")) + description = request.POST.get("description") + annotation_json = request.POST.get("annotation_json") + viewport_json = request.POST.get("viewport_json") + + series = Series.objects.get(pk=series_id) + findings = Finding.objects.filter(pk__in=findings_ids) + structures = Structure.objects.filter(pk__in=structure_ids) + + if series_finding_id > 0: + sf = get_object_or_404(SeriesFinding, pk=series_finding_id) + sf.series = series + sf.description = description + sf.annotation_json = annotation_json + sf.viewport_json = viewport_json + else: + sf = SeriesFinding.objects.create( + series=series, + description=description, + annotation_json=annotation_json, + viewport_json=viewport_json, + ) + + sf.findings.set(findings) + sf.structures.set(structures) + sf.save() + return JsonResponse({"success": True}) + + return JsonResponse({"success": False}) + return render(request, "create_post.html", {"posts": posts}) + + +class ConditionAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + # Don't forget to filter out results depending on the visitor ! + if not self.request.user.is_authenticated: + return Condition.objects.none() + + qs = Condition.objects.all() + + if self.q: + # This raises a fielderror which breaks creating a new item if not caught + try: + qs = qs.filter(name__icontains=self.q) + except FieldError: + return Condition.objects.none() + + return qs + + +class PresentationAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + # Don't forget to filter out results depending on the visitor ! + if not self.request.user.is_authenticated: + return Presentation.objects.none() + + qs = Presentation.objects.all() + + if self.q: + # This raises a fielderror which breaks creating a new item if not caught + try: + qs = qs.filter(name__icontains=self.q) + except FieldError: + return Presentation.objects.none() + + return qs + + +class FindingAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + # Don't forget to filter out results depending on the visitor ! + if not self.request.user.is_authenticated: + return Finding.objects.none() + + qs = Finding.objects.all() + + if self.q: + # This raises a fielderror which breaks creating a new item if not caught + try: + qs = qs.filter(name__icontains=self.q) + except FieldError: + return Finding.objects.none() + + return qs + + +class StructureAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + # Don't forget to filter out results depending on the visitor ! + if not self.request.user.is_authenticated: + return Structure.objects.none() + + qs = Structure.objects.all() + + if self.q: + # This raises a fielderror which breaks creating a new item if not caught + try: + qs = qs.filter(name__icontains=self.q) + except FieldError: + return Structure.objects.none() + + return qs + + +@login_required +@user_is_atlas_editor +def categories_list(request): + # condition = get_object_or_404(Condition, pk=pk) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "atlas/categories_list.html", + # { + # "condition": condition, + # }, + ) + + +def collection_index_view(request): + collections = CaseCollection.objects.all() + + return render(request, "atlas/collection_index_view.html", {"collections": collections}) + +def collection_detail_view(request, pk): + collection = get_object_or_404(CaseCollection, pk=pk) + + return render(request, "atlas/collection_detail_view.html", {"collection": collection}) + +def collection_case_view(request, pk, case_number): + collection = get_object_or_404(CaseCollection, pk=pk) + + cases = collection.cases.all().prefetch_related() + case = cases[case_number] + + series_list = case.series.all().prefetch_related("images", "examination", "plane") + + previous = case_number > 0 + next = case_number < (len(cases) - 1) + + return render(request, "atlas/collection_case_view.html", {"collection": collection, "case": case, "series_list": series_list, "case_number": case_number, "previous": previous, "next": next}) \ No newline at end of file diff --git a/generic/forms.py b/generic/forms.py index a60aa71e..e8e1a743 100755 --- a/generic/forms.py +++ b/generic/forms.py @@ -1,189 +1,189 @@ -from django.forms import ( - Form, - ModelForm, - ModelMultipleChoiceField, - ModelChoiceField, - ChoiceField, - CharField, - HiddenInput, -) -from django.forms import inlineformset_factory - -from generic.models import CidUser, CidUserGroup, Examination, QuestionNote - -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.forms.widgets import RadioSelect, TextInput, Textarea - -from rapids.models import Rapid -from anatomy.models import AnatomyQuestion -from longs.models import Long - -from django.shortcuts import render, get_object_or_404, redirect - -from django.contrib.contenttypes.models import ContentType - - -from physics.models import CidUserAnswer as PhysicsCidUserAnswer, Question -from physics.models import Exam as PhysicsExam -from physics.models import Question as SbasQuestion - -from anatomy.models import CidUserAnswer as AnatomyCidUserAnswer -from anatomy.models import Exam as AnatomyExam -from anatomy.models import AnatomyQuestion -from anatomy.models import Answer as AnatomyAnswer - -from rapids.models import CidUserAnswer as RapidsCidUserAnswer -from rapids.models import Exam as RapidsExam -from rapids.models import Rapid -from rapids.models import Answer as RapidAnswer - -from longs.models import CidUserAnswer as LongsCidUserAnswer -from longs.models import Exam as LongsExam -from longs.models import Long - -from sbas.models import CidUserAnswer as SbasCidUserAnswer -from sbas.models import Exam as SbasExam -from sbas.models import Question as SbasQuestion - -class ExaminationForm(ModelForm): - class Meta: - model = Examination - fields = ["examination"] - - -class QuestionNoteForm(ModelForm): - class Meta: - model = QuestionNote - fields = ["content_type", "object_id", "author", "note_type", "note"] - widgets = {"content_type": HiddenInput(), "object_id": HiddenInput()} - - def __init__(self, *args, **kwargs): - question_type = kwargs.pop("question_type") - pk = kwargs.pop("pk") - user = kwargs.pop("user") - - # if question_type == "rapid": - # question = Rapid - # content_type = ContentType.objects.get(model="rapid") - # elif question_type == "anatomy": - # question = AnatomyQuestion - # content_type = ContentType.objects.get(model="anatomyquestion") - # elif question_type == "long": - # question = Long - # content_type = ContentType.objects.get(model="long") - # else: - # raise PermissionError() - - # get_object_or_404(question, pk=pk) - # We get the 'initial' keyword argument or initialize it - # as a dict if it didn't exist. - # if kwargs.get("instance"): - ModelForm.__init__(self, *args, **kwargs) - super(QuestionNoteForm, self).__init__(*args, **kwargs) - - # initial = kwargs.setdefault("initial", {}) - ## The widget for a ModelMultipleChoiceField expects - ## a list of primary key for the selected data. - ##initial["content_type"] = question - ##initial["content_id"] = pk - # self.fields["content_type"].initial = content_type - # self.fields["object_id"].initial = pk - - -class CidUserForm(ModelForm): - - def __init__(self, *args, **kwargs): - if kwargs.get("instance"): - # We get the 'initial' keyword argument or initialize it - # as a dict if it didn't exist. - initial = kwargs.setdefault("initial", {}) - # The widget for a ModelMultipleChoiceField expects - # a list of primary key for the selected data. - initial["physics_exams"] = [t.pk for t in kwargs["instance"].physics_exams.all()] - initial["anatomy_exams"] = [t.pk for t in kwargs["instance"].anatomy_exams.all()] - initial["sba_exams"] = [t.pk for t in kwargs["instance"].sba_exams.all()] - initial["rapid_exams"] = [t.pk for t in kwargs["instance"].rapid_exams.all()] - initial["longs_exams"] = [t.pk for t in kwargs["instance"].longs_exams.all()] - - ModelForm.__init__(self, *args, **kwargs) - - super(CidUserForm, self).__init__(*args, **kwargs) - - self.fields["physics_exams"] = ModelMultipleChoiceField( - required=False, - queryset=PhysicsExam.objects.filter(archive=False), - widget=FilteredSelectMultiple(verbose_name="Physics Exams", is_stacked=False), - ) - self.fields["anatomy_exams"] = ModelMultipleChoiceField( - required=False, - queryset=AnatomyExam.objects.filter(archive=False), - widget=FilteredSelectMultiple(verbose_name="Anatomy Exams", is_stacked=False), - ) - self.fields["sba_exams"] = ModelMultipleChoiceField( - required=False, - queryset=SbasExam.objects.filter(archive=False), - widget=FilteredSelectMultiple(verbose_name="SBA Exams", is_stacked=False), - ) - self.fields["rapid_exams"] = ModelMultipleChoiceField( - required=False, - queryset=RapidsExam.objects.filter(archive=False), - widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False), - ) - self.fields["longs_exams"] = ModelMultipleChoiceField( - required=False, - queryset=LongsExam.objects.filter(archive=False), - widget=FilteredSelectMultiple(verbose_name="Long Exams", is_stacked=False), - ) - - def save(self, commit=True): - # Get the unsaved Long instance - instance = ModelForm.save(self, False) - - # Prepare a 'save_m2m' method for the form, - old_save_m2m = self.save_m2m - - def save_m2m(): - old_save_m2m() - instance.physics_exams.clear() - for physics_exams in self.cleaned_data["physics_exams"]: - instance.physics_exams.add(physics_exams.pk) - instance.anatomy_exams.clear() - for anatomy_exams in self.cleaned_data["anatomy_exams"]: - instance.anatomy_exams.add(anatomy_exams.pk) - instance.sba_exams.clear() - for sba_exams in self.cleaned_data["sba_exams"]: - instance.sba_exams.add(sba_exams.pk) - instance.rapid_exams.clear() - for rapid_exams in self.cleaned_data["rapid_exams"]: - instance.rapid_exams.add(rapid_exams.pk) - instance.longs_exams.clear() - for longs_exams in self.cleaned_data["longs_exams"]: - instance.longs_exams.add(longs_exams.pk) - - self.save_m2m = save_m2m - - # Do we need to save all changes now? - # if commit: - instance.save() - self.save_m2m() - - return instance - - class Meta: - model = CidUser - fields = [ - "passcode", - "active", - "internal_candidate", - "name", - "email", - "supervisor_email", - "login_email_sent", - "results_email_sent", - "group", - ] - -class CidUserGroupForm(ModelForm): - class Meta: - model = CidUserGroup +from django.forms import ( + Form, + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, + CharField, + HiddenInput, +) +from django.forms import inlineformset_factory + +from generic.models import CidUser, CidUserGroup, Examination, QuestionNote + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms.widgets import RadioSelect, TextInput, Textarea + +from rapids.models import Rapid +from anatomy.models import AnatomyQuestion +from longs.models import Long + +from django.shortcuts import render, get_object_or_404, redirect + +from django.contrib.contenttypes.models import ContentType + + +from physics.models import CidUserAnswer as PhysicsCidUserAnswer, Question +from physics.models import Exam as PhysicsExam +from physics.models import Question as SbasQuestion + +from anatomy.models import CidUserAnswer as AnatomyCidUserAnswer +from anatomy.models import Exam as AnatomyExam +from anatomy.models import AnatomyQuestion +from anatomy.models import Answer as AnatomyAnswer + +from rapids.models import CidUserAnswer as RapidsCidUserAnswer +from rapids.models import Exam as RapidsExam +from rapids.models import Rapid +from rapids.models import Answer as RapidAnswer + +from longs.models import CidUserAnswer as LongsCidUserAnswer +from longs.models import Exam as LongsExam +from longs.models import Long + +from sbas.models import CidUserAnswer as SbasCidUserAnswer +from sbas.models import Exam as SbasExam +from sbas.models import Question as SbasQuestion + +class ExaminationForm(ModelForm): + class Meta: + model = Examination + fields = ["examination"] + + +class QuestionNoteForm(ModelForm): + class Meta: + model = QuestionNote + fields = ["content_type", "object_id", "author", "note_type", "note"] + widgets = {"content_type": HiddenInput(), "object_id": HiddenInput()} + + def __init__(self, *args, **kwargs): + question_type = kwargs.pop("question_type") + pk = kwargs.pop("pk") + user = kwargs.pop("user") + + # if question_type == "rapid": + # question = Rapid + # content_type = ContentType.objects.get(model="rapid") + # elif question_type == "anatomy": + # question = AnatomyQuestion + # content_type = ContentType.objects.get(model="anatomyquestion") + # elif question_type == "long": + # question = Long + # content_type = ContentType.objects.get(model="long") + # else: + # raise PermissionError() + + # get_object_or_404(question, pk=pk) + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + # if kwargs.get("instance"): + ModelForm.__init__(self, *args, **kwargs) + super(QuestionNoteForm, self).__init__(*args, **kwargs) + + # initial = kwargs.setdefault("initial", {}) + ## The widget for a ModelMultipleChoiceField expects + ## a list of primary key for the selected data. + ##initial["content_type"] = question + ##initial["content_id"] = pk + # self.fields["content_type"].initial = content_type + # self.fields["object_id"].initial = pk + + +class CidUserForm(ModelForm): + + def __init__(self, *args, **kwargs): + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + initial["physics_exams"] = [t.pk for t in kwargs["instance"].physics_exams.all()] + initial["anatomy_exams"] = [t.pk for t in kwargs["instance"].anatomy_exams.all()] + initial["sba_exams"] = [t.pk for t in kwargs["instance"].sba_exams.all()] + initial["rapid_exams"] = [t.pk for t in kwargs["instance"].rapid_exams.all()] + initial["longs_exams"] = [t.pk for t in kwargs["instance"].longs_exams.all()] + + ModelForm.__init__(self, *args, **kwargs) + + super(CidUserForm, self).__init__(*args, **kwargs) + + self.fields["physics_exams"] = ModelMultipleChoiceField( + required=False, + queryset=PhysicsExam.objects.filter(archive=False), + widget=FilteredSelectMultiple(verbose_name="Physics Exams", is_stacked=False), + ) + self.fields["anatomy_exams"] = ModelMultipleChoiceField( + required=False, + queryset=AnatomyExam.objects.filter(archive=False), + widget=FilteredSelectMultiple(verbose_name="Anatomy Exams", is_stacked=False), + ) + self.fields["sba_exams"] = ModelMultipleChoiceField( + required=False, + queryset=SbasExam.objects.filter(archive=False), + widget=FilteredSelectMultiple(verbose_name="SBA Exams", is_stacked=False), + ) + self.fields["rapid_exams"] = ModelMultipleChoiceField( + required=False, + queryset=RapidsExam.objects.filter(archive=False), + widget=FilteredSelectMultiple(verbose_name="Rapid Exams", is_stacked=False), + ) + self.fields["longs_exams"] = ModelMultipleChoiceField( + required=False, + queryset=LongsExam.objects.filter(archive=False), + widget=FilteredSelectMultiple(verbose_name="Long Exams", is_stacked=False), + ) + + def save(self, commit=True): + # Get the unsaved Long instance + instance = ModelForm.save(self, False) + + # Prepare a 'save_m2m' method for the form, + old_save_m2m = self.save_m2m + + def save_m2m(): + old_save_m2m() + instance.physics_exams.clear() + for physics_exams in self.cleaned_data["physics_exams"]: + instance.physics_exams.add(physics_exams.pk) + instance.anatomy_exams.clear() + for anatomy_exams in self.cleaned_data["anatomy_exams"]: + instance.anatomy_exams.add(anatomy_exams.pk) + instance.sba_exams.clear() + for sba_exams in self.cleaned_data["sba_exams"]: + instance.sba_exams.add(sba_exams.pk) + instance.rapid_exams.clear() + for rapid_exams in self.cleaned_data["rapid_exams"]: + instance.rapid_exams.add(rapid_exams.pk) + instance.longs_exams.clear() + for longs_exams in self.cleaned_data["longs_exams"]: + instance.longs_exams.add(longs_exams.pk) + + self.save_m2m = save_m2m + + # Do we need to save all changes now? + # if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CidUser + fields = [ + "passcode", + "active", + "internal_candidate", + "name", + "email", + "supervisor_email", + "login_email_sent", + "results_email_sent", + "group", + ] + +class CidUserGroupForm(ModelForm): + class Meta: + model = CidUserGroup fields = ["name", "archive"] \ No newline at end of file diff --git a/generic/migrations/0025_alter_ciduser_group.py b/generic/migrations/0025_alter_ciduser_group.py index 8c812f25..f961d060 100644 --- a/generic/migrations/0025_alter_ciduser_group.py +++ b/generic/migrations/0025_alter_ciduser_group.py @@ -1,19 +1,19 @@ -# Generated by Django 3.2.10 on 2022-03-01 18:32 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('generic', '0024_auto_20211231_1044'), - ] - - operations = [ - migrations.AlterField( - model_name='ciduser', - name='group', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='generic.cidusergroup'), - ), - ] +# Generated by Django 3.2.10 on 2022-03-01 18:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('generic', '0024_auto_20211231_1044'), + ] + + operations = [ + migrations.AlterField( + model_name='ciduser', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='generic.cidusergroup'), + ), + ] diff --git a/generic/models.py b/generic/models.py index d5c8cafe..95363aef 100644 --- a/generic/models.py +++ b/generic/models.py @@ -1,469 +1,469 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.db import models -from django.http import HttpRequest -from django.utils import timezone -from smtplib import SMTPException - -from django.urls import reverse -from django.views.generic.edit import CreateView -from reversion.views import RevisionMixin - -import tagulous -import tagulous.models - -from sortedm2m.fields import SortedManyToManyField -from django.conf import settings - -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType - -from django.core.mail import send_mail -from django.core.mail import EmailMessage - - -class Plane(models.Model): - plane = models.CharField(max_length=200, unique=True) - - def __str__(self): - return self.plane - - class Meta: - ordering = ("plane",) - - -class Contrast(models.Model): - contrast = models.CharField(max_length=200, unique=True) - - def __str__(self): - return self.contrast - - class Meta: - ordering = ("contrast",) - - -class Examination(models.Model): - examination = models.CharField(max_length=200, unique=True) - - def __str__(self): - return self.examination - - class Meta: - ordering = ("examination",) - - -class Site(models.Model): - site = models.CharField(max_length=200) - initials = models.CharField(max_length=200) - - def __str__(self): - return self.site - - -class Condition(tagulous.models.TagModel): - pass - - -class Sign(tagulous.models.TagModel): - pass - - -class ExamBase(models.Model): - name = models.CharField(max_length=200, help_text="Name of the exam") - # exam_questions = SortedManyToManyField(Long, related_name="exams", blank="true") - - active = models.BooleanField( - help_text="If an exam should be available to take", default=False - ) - - exam_mode = models.BooleanField( - help_text="If an exam should be taken in exam mode (users results will be submited to the server for marking)", - default=False, - ) - - publish_results = models.BooleanField( - help_text="If an exams results should be available", default=False - ) - - recreate_json = models.BooleanField( - help_text="If the json cache needs updating", default=False - ) - - json_creation_time = models.DateTimeField(blank=True, default=None, null=True) - exam_json_id = models.IntegerField( - default=1, help_text="auto incrementing field when json recreated" - ) - - archive = models.BooleanField( - help_text="Archived exams will remain on the test system but will not be displayed by default", - default=False, - ) - - open_access = models.BooleanField( - help_text="If the exam is freely accessible (to view and edit on the test system)", - default=False, - ) - - stats_mean = models.FloatField(default=0) - stats_mode = models.CharField(default=0, max_length=25) - stats_median = models.FloatField(default=0) - - stats_candidates = models.FloatField(default=0) - - stats_min = models.FloatField(default=0) - stats_max = models.FloatField(default=0) - - stats_max_possible = models.FloatField(default=0) - - stats_graph = models.TextField(default=0) - - user_scores = models.JSONField(default=dict) - - notes = GenericRelation("generic.CidUserExam") - - # time_limit = models.IntegerField( - # help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)", - # default=2100, - # ) - - class Meta: - abstract = True - - def get_exam_stats(self): - text = f"""{self.name} -{"-"*len(self.name)} -- Candidates: {int(self.stats_candidates)} -- Mean: {self.stats_mean:.2f} Median: {self.stats_median} Mode: {self.stats_mode} [Min: {self.stats_min} / Max: {self.stats_max}] -""" - return text - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse("{}:exam_overview".format(self.app_name), kwargs={"pk": self.pk}) - - def get_take_url(self): - return f"{settings.REMOTE_URL}/rts" - - def get_exam_name(self): - return self.name - - def get_json_url(self): - return reverse("{}:exam_json".format(self.app_name), args=(self.pk,)) - - def get_question_index(self, question): - return list(self.exam_questions.all()).index(question) - - def get_authors(self): - """Returns a comma seperated text list of authors""" - authors = ", ".join([i.username for i in self.author.all()]) - return authors - - def get_author_objects(self): - """Returns a comma seperated text list of authors""" - authors = [i for i in self.author.all()] - return authors - - def check_cid_user(self, cid, passcode, request=None): - if request is not None and request.user.is_superuser: - return True - - if self.valid_users.exists(): - - user = self.valid_users.filter(cid=cid).first() - - if not user or user.passcode != passcode: - return False - - return True - - def get_or_create_cid_user_exam(self, cid_user, start_time=None): - content_type = ContentType.objects.get_for_model(self) - c = CidUserExam.objects.filter( - content_type=content_type, object_id=self.pk, cid_user=cid_user - ).first() - print(c) - if c: - return c - - if start_time is None: - start_time = timezone.now() - new = CidUserExam( - content_type=content_type, - object_id=self.pk, - cid_user=cid_user, - start_time=start_time, - ) - new.save() - return new - - def get_cid_user_exams(self, cid_user=None): - content_type = ContentType.objects.get_for_model(self) - if cid_user is None: - return CidUserExam.objects.filter( - content_type=content_type, - object_id=self.pk, - ) - else: - return CidUserExam.objects.filter( - content_type=content_type, object_id=self.pk, cid_user=cid_user - ) - - def get_cid_user_score(self, cid_user): - c = str(cid_user) - if c in self.user_scores: - return self.user_scores[c] - else: - return False - - -class NoteType(models.Model): - note_type = models.CharField(max_length=200) - - def __str__(self): - return self.note_type - - -class QuestionNote(models.Model): - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - question = GenericForeignKey("content_type", "object_id") - # author = models.ForeignKey(User, - - author = models.CharField(max_length=200, blank=True) - - user_author = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True - ) - - note = models.TextField(blank=True) - created_on = models.DateTimeField(auto_now_add=True) - - complete = models.BooleanField(default=False) - - note_type = models.ForeignKey( - NoteType, on_delete=models.CASCADE, null=True, blank=True - ) - - # def get_absolute_url(self): - # self.pk = self.rapid_id - # return reverse('rapids:question_detail', kwargs={'pk': self.pk}) - - def __str__(self): - return "{}: {} [{}] {} / {}".format( - self.content_type, - self.get_author_str(), - self.created_on, - self.note_type, - self.note, - ) - - def get_absolute_url(self): - # TODO: return to question if logged in - return "/" - - def get_object_url(self): - return self.question.get_absolute_url() - - def get_author_str(self): - if self.user_author is not None: - return self.user_author.username - else: - return self.author - - def get_short_str(self): - return f"{self.note_type} - {self.note}" - - -class CidUser(models.Model): - EXAM_TYPES = ( - ("Physics", "physics_exams"), - ("Rapids", "rapid_exams"), - ("SBAs", "sba_exams"), - ("Anatomy", "anatomy_exams"), - ("Longs", "longs_exams"), - ) - - cid = models.IntegerField(blank=True, null=True, unique=True) - passcode = models.CharField(blank=True, max_length=50) - active = models.BooleanField(default=True) - internal_candidate = models.BooleanField(default=False) - name = models.CharField(blank=True, max_length=255) - email = models.EmailField(blank=True) - supervisor_email = models.EmailField(blank=True) - - login_email_sent = models.BooleanField(default=False) - results_email_sent = models.BooleanField(default=False) - - group = models.ForeignKey("CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True) - - def __str__(self) -> str: - return str(self.cid) - - def get_absolute_url(self): - return reverse("generic:manage_cids") - - def check_passcode(self, passcode) -> bool: - return self.passcode == passcode - - def get_cid_exams(self): - available_exams = [] - - for n, t in self.EXAM_TYPES: - exam_rel = getattr(self, t) - if exam_rel.exists(): - exams = exam_rel.filter(exam_mode=True, archive=False).order_by("name") - available_exams.append((n, exams)) - - return available_exams - - def generate_exam_report(self): - exam_list = self.get_cid_exams() - - name = "" - if self.name: - name = f"{self.name} " - - exam_text = [f"Candidate {self.cid} {name}[{self.email}]"] - - for exam_type, exams in exam_list: - exam_text.append(f"{'='*len(exam_type)}\n{exam_type}\n{'='*len(exam_type)}") - - for exam in exams: - try: - user_score = exam.user_scores[str(self.cid)] - except KeyError: - user_score = "Score not generated" - t = exam.get_exam_stats() - - try: - percentage = ( - f" ({user_score / int(exam.stats_max_possible) * 100:.2f}%)" - ) - except TypeError: - percentage = "" - - exam_text.append( - f"""{t} -# Score: {user_score} / {int(exam.stats_max_possible)}{percentage} -""" - ) - - msg = "\n\n".join(exam_text) - - return msg - - html_msg = f"""""" - - def email_results(self, resend=False, additional_emails=[]): - if not self.internal_candidate: - return False, "Not internal candidate." - - if self.results_email_sent and not resend: - return False, "Already sent." - - # Get a list of taken exams - msg = self.generate_exam_report() - - emails = [self.email, self.supervisor_email] - - if additional_emails: - emails.extend(additional_emails) - - try: - send_mail( - f"{self.group} results", - msg, - "test@xkjq.uk", - emails, - fail_silently=False, - # html_message=html_msg, - ) - except SMTPException as e: - return False, e - - self.results_email_sent = True - self.save() - return True, "" - - def email_details(self, resend=False): - if self.login_email_sent and not resend: - return False, "Already sent." - - login_url = reverse("cid_selector") - - msg = f""" - See below for your details for: {self.group} - - --------- - CID: {self.cid} - Passcode: {self.passcode} - --------- - - You will need these to access to the test system. - - Direct link to login: - {settings.REMOTE_URL}{login_url}?cid={self.cid}&passcode={self.passcode} - - The above details are unique to you. Please do not share as otherwise others will be able to access and submit your results. - - - - Please note this inbox is not monitored. Direct all enquiries to plh-tr.radiology-academy@nhs.net - """ - - try: - # send_mail( - # "Candidate Details", - # msg, - # "test@xkjq.uk", - # [self.email], - # fail_silently=False, - # ) - email = EmailMessage( - "Candidate Details", - msg, - "test@xkjq.uk", - [self.email], - ["ross.kruger@nhs.net"], - ) - email.send(fail_silently=False) - except SMTPException as e: - return False, e - - self.login_email_sent = True - self.save() - return True, "" - - def get_email_results_url(self): - return reverse("generic:candidate_email_results", kwargs={"cid": self.cid}) - - def get_email_results_resend_url(self): - return reverse("generic:candidate_email_results_resend", kwargs={"cid": self.cid}) - - -class CidUserExam(models.Model): - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - exam = GenericForeignKey("content_type", "object_id") - - start_time = models.DateTimeField(blank=True, null=True) - end_time = models.DateTimeField(blank=True, null=True) - - cid_user = models.ForeignKey(CidUser, on_delete=models.CASCADE) - - manual_submission = models.BooleanField(default=False) - - -class CidUserGroup(models.Model): - name = models.CharField(blank=True, max_length=50) - archive = models.BooleanField(default=False) - - def __str__(self) -> str: - return str(self.name) - - def GetGroupUsers(self): - cid_users = self.ciduser_set.all() - - s = ", ".join([f"{user.id} - {user.name}" for user in cid_users]) - return s +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db import models +from django.http import HttpRequest +from django.utils import timezone +from smtplib import SMTPException + +from django.urls import reverse +from django.views.generic.edit import CreateView +from reversion.views import RevisionMixin + +import tagulous +import tagulous.models + +from sortedm2m.fields import SortedManyToManyField +from django.conf import settings + +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType + +from django.core.mail import send_mail +from django.core.mail import EmailMessage + + +class Plane(models.Model): + plane = models.CharField(max_length=200, unique=True) + + def __str__(self): + return self.plane + + class Meta: + ordering = ("plane",) + + +class Contrast(models.Model): + contrast = models.CharField(max_length=200, unique=True) + + def __str__(self): + return self.contrast + + class Meta: + ordering = ("contrast",) + + +class Examination(models.Model): + examination = models.CharField(max_length=200, unique=True) + + def __str__(self): + return self.examination + + class Meta: + ordering = ("examination",) + + +class Site(models.Model): + site = models.CharField(max_length=200) + initials = models.CharField(max_length=200) + + def __str__(self): + return self.site + + +class Condition(tagulous.models.TagModel): + pass + + +class Sign(tagulous.models.TagModel): + pass + + +class ExamBase(models.Model): + name = models.CharField(max_length=200, help_text="Name of the exam") + # exam_questions = SortedManyToManyField(Long, related_name="exams", blank="true") + + active = models.BooleanField( + help_text="If an exam should be available to take", default=False + ) + + exam_mode = models.BooleanField( + help_text="If an exam should be taken in exam mode (users results will be submited to the server for marking)", + default=False, + ) + + publish_results = models.BooleanField( + help_text="If an exams results should be available", default=False + ) + + recreate_json = models.BooleanField( + help_text="If the json cache needs updating", default=False + ) + + json_creation_time = models.DateTimeField(blank=True, default=None, null=True) + exam_json_id = models.IntegerField( + default=1, help_text="auto incrementing field when json recreated" + ) + + archive = models.BooleanField( + help_text="Archived exams will remain on the test system but will not be displayed by default", + default=False, + ) + + open_access = models.BooleanField( + help_text="If the exam is freely accessible (to view and edit on the test system)", + default=False, + ) + + stats_mean = models.FloatField(default=0) + stats_mode = models.CharField(default=0, max_length=25) + stats_median = models.FloatField(default=0) + + stats_candidates = models.FloatField(default=0) + + stats_min = models.FloatField(default=0) + stats_max = models.FloatField(default=0) + + stats_max_possible = models.FloatField(default=0) + + stats_graph = models.TextField(default=0) + + user_scores = models.JSONField(default=dict) + + notes = GenericRelation("generic.CidUserExam") + + # time_limit = models.IntegerField( + # help_text="Exam time limit (in seconds). Default is 2100 secondse (35 minutes)", + # default=2100, + # ) + + class Meta: + abstract = True + + def get_exam_stats(self): + text = f"""{self.name} +{"-"*len(self.name)} +- Candidates: {int(self.stats_candidates)} +- Mean: {self.stats_mean:.2f} Median: {self.stats_median} Mode: {self.stats_mode} [Min: {self.stats_min} / Max: {self.stats_max}] +""" + return text + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("{}:exam_overview".format(self.app_name), kwargs={"pk": self.pk}) + + def get_take_url(self): + return f"{settings.REMOTE_URL}/rts" + + def get_exam_name(self): + return self.name + + def get_json_url(self): + return reverse("{}:exam_json".format(self.app_name), args=(self.pk,)) + + def get_question_index(self, question): + return list(self.exam_questions.all()).index(question) + + def get_authors(self): + """Returns a comma seperated text list of authors""" + authors = ", ".join([i.username for i in self.author.all()]) + return authors + + def get_author_objects(self): + """Returns a comma seperated text list of authors""" + authors = [i for i in self.author.all()] + return authors + + def check_cid_user(self, cid, passcode, request=None): + if request is not None and request.user.is_superuser: + return True + + if self.valid_users.exists(): + + user = self.valid_users.filter(cid=cid).first() + + if not user or user.passcode != passcode: + return False + + return True + + def get_or_create_cid_user_exam(self, cid_user, start_time=None): + content_type = ContentType.objects.get_for_model(self) + c = CidUserExam.objects.filter( + content_type=content_type, object_id=self.pk, cid_user=cid_user + ).first() + print(c) + if c: + return c + + if start_time is None: + start_time = timezone.now() + new = CidUserExam( + content_type=content_type, + object_id=self.pk, + cid_user=cid_user, + start_time=start_time, + ) + new.save() + return new + + def get_cid_user_exams(self, cid_user=None): + content_type = ContentType.objects.get_for_model(self) + if cid_user is None: + return CidUserExam.objects.filter( + content_type=content_type, + object_id=self.pk, + ) + else: + return CidUserExam.objects.filter( + content_type=content_type, object_id=self.pk, cid_user=cid_user + ) + + def get_cid_user_score(self, cid_user): + c = str(cid_user) + if c in self.user_scores: + return self.user_scores[c] + else: + return False + + +class NoteType(models.Model): + note_type = models.CharField(max_length=200) + + def __str__(self): + return self.note_type + + +class QuestionNote(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + question = GenericForeignKey("content_type", "object_id") + # author = models.ForeignKey(User, + + author = models.CharField(max_length=200, blank=True) + + user_author = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True + ) + + note = models.TextField(blank=True) + created_on = models.DateTimeField(auto_now_add=True) + + complete = models.BooleanField(default=False) + + note_type = models.ForeignKey( + NoteType, on_delete=models.CASCADE, null=True, blank=True + ) + + # def get_absolute_url(self): + # self.pk = self.rapid_id + # return reverse('rapids:question_detail', kwargs={'pk': self.pk}) + + def __str__(self): + return "{}: {} [{}] {} / {}".format( + self.content_type, + self.get_author_str(), + self.created_on, + self.note_type, + self.note, + ) + + def get_absolute_url(self): + # TODO: return to question if logged in + return "/" + + def get_object_url(self): + return self.question.get_absolute_url() + + def get_author_str(self): + if self.user_author is not None: + return self.user_author.username + else: + return self.author + + def get_short_str(self): + return f"{self.note_type} - {self.note}" + + +class CidUser(models.Model): + EXAM_TYPES = ( + ("Physics", "physics_exams"), + ("Rapids", "rapid_exams"), + ("SBAs", "sba_exams"), + ("Anatomy", "anatomy_exams"), + ("Longs", "longs_exams"), + ) + + cid = models.IntegerField(blank=True, null=True, unique=True) + passcode = models.CharField(blank=True, max_length=50) + active = models.BooleanField(default=True) + internal_candidate = models.BooleanField(default=False) + name = models.CharField(blank=True, max_length=255) + email = models.EmailField(blank=True) + supervisor_email = models.EmailField(blank=True) + + login_email_sent = models.BooleanField(default=False) + results_email_sent = models.BooleanField(default=False) + + group = models.ForeignKey("CidUserGroup", on_delete=models.SET_NULL, null=True, blank=True) + + def __str__(self) -> str: + return str(self.cid) + + def get_absolute_url(self): + return reverse("generic:manage_cids") + + def check_passcode(self, passcode) -> bool: + return self.passcode == passcode + + def get_cid_exams(self): + available_exams = [] + + for n, t in self.EXAM_TYPES: + exam_rel = getattr(self, t) + if exam_rel.exists(): + exams = exam_rel.filter(exam_mode=True, archive=False).order_by("name") + available_exams.append((n, exams)) + + return available_exams + + def generate_exam_report(self): + exam_list = self.get_cid_exams() + + name = "" + if self.name: + name = f"{self.name} " + + exam_text = [f"Candidate {self.cid} {name}[{self.email}]"] + + for exam_type, exams in exam_list: + exam_text.append(f"{'='*len(exam_type)}\n{exam_type}\n{'='*len(exam_type)}") + + for exam in exams: + try: + user_score = exam.user_scores[str(self.cid)] + except KeyError: + user_score = "Score not generated" + t = exam.get_exam_stats() + + try: + percentage = ( + f" ({user_score / int(exam.stats_max_possible) * 100:.2f}%)" + ) + except TypeError: + percentage = "" + + exam_text.append( + f"""{t} +# Score: {user_score} / {int(exam.stats_max_possible)}{percentage} +""" + ) + + msg = "\n\n".join(exam_text) + + return msg + + html_msg = f"""""" + + def email_results(self, resend=False, additional_emails=[]): + if not self.internal_candidate: + return False, "Not internal candidate." + + if self.results_email_sent and not resend: + return False, "Already sent." + + # Get a list of taken exams + msg = self.generate_exam_report() + + emails = [self.email, self.supervisor_email] + + if additional_emails: + emails.extend(additional_emails) + + try: + send_mail( + f"{self.group} results", + msg, + "test@xkjq.uk", + emails, + fail_silently=False, + # html_message=html_msg, + ) + except SMTPException as e: + return False, e + + self.results_email_sent = True + self.save() + return True, "" + + def email_details(self, resend=False): + if self.login_email_sent and not resend: + return False, "Already sent." + + login_url = reverse("cid_selector") + + msg = f""" + See below for your details for: {self.group} + + --------- + CID: {self.cid} + Passcode: {self.passcode} + --------- + + You will need these to access to the test system. + + Direct link to login: + {settings.REMOTE_URL}{login_url}?cid={self.cid}&passcode={self.passcode} + + The above details are unique to you. Please do not share as otherwise others will be able to access and submit your results. + + + + Please note this inbox is not monitored. Direct all enquiries to plh-tr.radiology-academy@nhs.net + """ + + try: + # send_mail( + # "Candidate Details", + # msg, + # "test@xkjq.uk", + # [self.email], + # fail_silently=False, + # ) + email = EmailMessage( + "Candidate Details", + msg, + "test@xkjq.uk", + [self.email], + ["ross.kruger@nhs.net"], + ) + email.send(fail_silently=False) + except SMTPException as e: + return False, e + + self.login_email_sent = True + self.save() + return True, "" + + def get_email_results_url(self): + return reverse("generic:candidate_email_results", kwargs={"cid": self.cid}) + + def get_email_results_resend_url(self): + return reverse("generic:candidate_email_results_resend", kwargs={"cid": self.cid}) + + +class CidUserExam(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + exam = GenericForeignKey("content_type", "object_id") + + start_time = models.DateTimeField(blank=True, null=True) + end_time = models.DateTimeField(blank=True, null=True) + + cid_user = models.ForeignKey(CidUser, on_delete=models.CASCADE) + + manual_submission = models.BooleanField(default=False) + + +class CidUserGroup(models.Model): + name = models.CharField(blank=True, max_length=50) + archive = models.BooleanField(default=False) + + def __str__(self) -> str: + return str(self.name) + + def GetGroupUsers(self): + cid_users = self.ciduser_set.all() + + s = ", ".join([f"{user.id} - {user.name}" for user in cid_users]) + return s diff --git a/generic/views.py b/generic/views.py index 2852d70b..358fd379 100644 --- a/generic/views.py +++ b/generic/views.py @@ -1,1650 +1,1650 @@ -from collections import defaultdict -import statistics -from dal import autocomplete -from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType -from django.forms.models import model_to_dict -from django.shortcuts import render, get_object_or_404, redirect - -from django.contrib.auth.decorators import login_required, user_passes_test -from django.urls.base import reverse -from django.utils import timezone -from django.utils.html import format_html -from django.views.decorators.csrf import csrf_exempt -from django.core.exceptions import PermissionDenied, FieldError - -from django.http import Http404, JsonResponse -from django.http import HttpResponseRedirect, HttpResponse - -from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin - - -from django.utils.decorators import method_decorator - - -from django.core.cache import cache - -import json -import urllib - -from django.views import View -from django.views.generic.edit import CreateView, UpdateView -from django_filters.views import FilterView -from django_tables2.views import SingleTableMixin -from reversion.views import RevisionMixin -from generic.decorators import user_is_cid_user_manager -from generic.filters import CidUserFilter - -from generic.tables import CidUserExamTable, CidUserTable - -from .forms import ( - CidUserForm, - ExaminationForm, - CidUserGroupForm, -) - -from .models import CidUser, CidUserGroup, Examination, QuestionNote - -from rapids.models import Rapid as RapidQuestion -from rapids.models import Exam as RapidExam -from longs.models import Long as LongQuestion -from longs.models import Exam as LongExam -from anatomy.models import AnatomyQuestion as AnatomyQuestion -from anatomy.models import Exam as AnatomyExam -from sbas.models import Question as SbasQuestion -from sbas.models import Exam as SbasExam -from physics.models import Question as PhysicsQuestion -from physics.models import Exam as PhysicsExam - -from django.db.models import Case, When -from django.conf import settings - -from datetime import datetime -import os -import string -import random -import plotly.express as px - -# from rad.views import get_question_and_content_type -from django.db.models import Prefetch - - -class CidManagerRequiredMixin(UserPassesTestMixin): - def test_func(self): - return self.request.user.groups.filter(name="cid_user_manager").exists() - - -# def get_object(self, *args, **kwargs): -# obj = super().get_object(*args, **kwargs) -# if self.request.user.groups.filter(name="cid_user_manager").exists(): -# return obj -# raise PermissionDenied() # or Http404 - - -def normaliseRapidsScore(score): - if score == 49: - return 4.5 - elif score in [50, 51]: - return 5 - elif score in [52, 53]: - return 5.5 - elif score in [54]: - return 6 - elif score in [55, 56]: - return 6.5 - elif score in [57, 58]: - return 7 - elif score in [59]: - return 7.5 - elif score in [60]: - return 8 - - return 4 - - -def get_question_and_content_type(question_type): - if question_type == "rapid": - question = RapidQuestion - content_type = ContentType.objects.get(model="rapid") - elif question_type == "anatomy": - question = AnatomyQuestion - content_type = ContentType.objects.get(model="anatomyquestion") - elif question_type == "long": - question = LongQuestion - content_type = ContentType.objects.get(model="long") - elif question_type == "sbas": - question = SbasQuestion - content_type = ContentType.objects.get(app_label="sbas", model="question") - elif question_type == "physics": - question = PhysicsQuestion - content_type = ContentType.objects.get(app_label="physics", model="question") - else: - raise PermissionError() - - return question, content_type - - -# Create your views here. -@login_required -def create_examination(request): - form = ExaminationForm(request.POST or None) - if form.is_valid(): - instance = form.save() - return HttpResponse( - '' - % (instance.pk, instance) - ) - return render( - request, "rapids/create_simple.html", {"form": form, "name": "Examination"} - ) - - -@csrf_exempt -def get_examination_id(request): - if request.is_ajax(): - examination_name = request.GET["examination_name"] - examination_id = Examination.objects.get(name=examination_name).id - data = { - "examination_id": examination_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -# Generic view to dispatch to indivuals app views -@login_required -def generic_exam_json_edit(request): - if request.is_ajax() and request.method == "POST": - question_type = request.POST.get("type") - - if question_type == "rapid": - Exam = RapidExam - Question = RapidQuestion - elif question_type == "long": - Exam = LongExam - Question = LongQuestion - elif question_type == "anatomy": - Exam = AnatomyExam - Question = AnatomyQuestion - elif question_type == "physics": - Exam = PhysicsExam - Question = PhysicsQuestion - elif question_type == "sbas": - Exam = SbasExam - Question = SbasQuestion - else: - data = {"status": "error"} - return JsonResponse(data, status=400) - - exam = get_object_or_404(Exam, pk=request.POST.get("exam_id")) - - # if "exam_toggle_active" in request.POST: - # active = json.loads(request.POST.get("active")) - - # data = {"status": "success"} - # return JsonResponse(data, status=200) - - if "add_exam_questions" in request.POST: - question_ids = json.loads(request.POST.get("add_exam_questions")) - # question_objects = Question.objects.filter(pk__in=question_ids) - - add_count = 0 - for i in question_ids: - # We get an integrity error if we try to add an object that already exists - if not exam.exam_questions.filter(pk=i).exists(): - add_count += 1 - exam.exam_questions.add(i) - - if add_count > 0: - exam.save() - data = {"status": "success", "questions added": add_count} - return JsonResponse(data, status=200) - - if "set_exam_order" in request.POST: - new_order = json.loads(request.POST.get("set_exam_order")) - - preserved = Case( - *[When(pk=pk, then=pos) for pos, pk in enumerate(new_order)] - ) - objects = Question.objects.filter(pk__in=new_order).order_by(preserved) - - if len(objects) != exam.exam_questions.count(): - data = {"status": "error, count does not match"} - return JsonResponse(data, status=400) - - exam.exam_questions.set(objects) - exam.save() - data = {"status": "success"} - return JsonResponse(data, status=200) - - # Only checkers can do the following - if ( - question_type == "rapid" - and not request.user.groups.filter(name="rapid_checker").exists() - ): - data = {"status": "error, permission denied"} - return JsonResponse(data, status=400) - if ( - question_type == "anatomy" - and not request.user.groups.filter(name="anatomy_checker").exists() - ): - data = {"status": "error, permission denied"} - return JsonResponse(data, status=400) - if ( - question_type == "long" - and not request.user.groups.filter(name="long_checker").exists() - ): - data = {"status": "error, permission denied"} - return JsonResponse(data, status=400) - - if "set_open_access" in request.POST: - if request.POST.get("set_open_access") == "true": - state = True - else: - state = False - - questions_changed = [] - for q in exam.exam_questions.all(): - q.open_access = state - q.save() - questions_changed.append(q.pk) - - data = {"status": "success", "questions": questions_changed, "state": state} - return JsonResponse(data, status=200) - - data = {"status": "error, unknown request"} - return JsonResponse(data, status=400) - else: - data = {"status": "error"} - return JsonResponse(data, status=400) - - -class ExamViews(View, LoginRequiredMixin): - def __init__( - self, - exam, - question, - answer, - cid_user_answer, - app, - question_type, - loadJsonAnswer, - ): - self.Exam = exam - self.Question = question - self.Answer = answer - self.CidUserAnswer = cid_user_answer - self.app_name = app - self.question_type = question_type - self.loadJsonAnswer = loadJsonAnswer - - # THis may be better than check_user_access below - # group_map = {"rapids" : "rapid_checker", "anatomy" : "anatomy_checker", "longs":"long_checker"} - # exam_group = group_map[self.app_name] - - def check_user_access(self, user, exam_id=None): - """Check if a user should be able to access a view - - Args: - user ([user]): user object - - Returns: - [boolean]: True if the user has access - """ - print("****", user, exam_id) - if exam_id is not None: - exam = get_object_or_404(self.Exam, pk=exam_id) - if (exam.open_access and exam.active) or user in exam.get_author_objects(): - return True - - if ( - self.app_name == "rapids" - and not user.groups.filter(name="rapid_checker").exists() - ): - return False - if ( - self.app_name == "anatomy" - and not user.groups.filter( - name__in=("anatomy_checker", "anatomy_marker") - ).exists() - ): - return False - if ( - self.app_name == "longs" - and not user.groups.filter( - name__in=("long_checker", "long_marker") - ).exists() - ): - return False - if ( - self.app_name == "physics" - and not user.groups.filter(name="physics_checker").exists() - ): - return False - if ( - self.app_name == "sbas" - and not user.groups.filter(name="sba_checker").exists() - ): - return False - return True - - def check_user_edit_access(self, user, exam_id=None): - """Check if a user should be able to access a view - - Args: - user ([user]): user object - - Returns: - [boolean]: True if the user has access - """ - # If a user is an exam author they should have acccess - if exam_id is not None: - exam = get_object_or_404(self.Exam, pk=exam_id) - if exam.open_access or user in exam.get_author_objects(): - return True - - if ( - self.app_name == "rapids" - and not user.groups.filter(name="rapid_checker").exists() - ): - return False - if ( - self.app_name == "anatomy" - and not user.groups.filter(name="anatomy_checker").exists() - ): - return False - if ( - self.app_name == "longs" - and not user.groups.filter(name__in=("long_checker",)).exists() - ): - return False - if ( - self.app_name == "physics" - and not user.groups.filter(name="physics_checker").exists() - ): - return False - if ( - self.app_name == "sbas" - and not user.groups.filter(name="sba_checker").exists() - ): - return False - return True - - @method_decorator(login_required) - def exam_toggle_results_published(self, request, pk): - if request.is_ajax() and request.method == "POST": - - if not self.check_user_edit_access(request.user): - data = {"status": "error, insufficient permission"} - return JsonResponse(data, status=400) - - exam = get_object_or_404(self.Exam, pk=pk) - - exam.publish_results = ( - True if request.POST.get("publish_results") == "true" else False - ) - exam.save() - data = { - "status": "success", - "publish_results": exam.publish_results, - "name": exam.name, - "id": exam.id, - } - return JsonResponse(data, status=200) - else: - data = {"status": "error"} - return JsonResponse(data, status=400) - - @method_decorator(login_required) - def exam_toggle_active(self, request, pk): - if request.is_ajax() and request.method == "POST": - - if not self.check_user_edit_access(request.user): - data = {"status": "error, insufficient permission"} - return JsonResponse(data, status=400) - - exam = get_object_or_404(self.Exam, pk=pk) - - exam.active = True if request.POST.get("active") == "true" else False - exam.save() - data = { - "status": "success", - "active": exam.active, - "name": exam.name, - "id": exam.id, - } - return JsonResponse(data, status=200) - else: - data = {"status": "error"} - return JsonResponse(data, status=400) - - @method_decorator(login_required) - def exam_json_recreate(self, request, pk): - exam = get_object_or_404(self.Exam, pk=pk) - - exam.recreate_json = True - exam.save() - - exam.get_exam_json() - - return redirect("{}:exam_overview".format(self.app_name), pk=pk) - - @method_decorator(login_required) - def question_json_recreate(self, request, pk): - question = get_object_or_404(self.Question, pk=pk) - - question.recreate_json = True - question.save() - - return redirect("{}:question_detail".format(self.app_name), pk=pk) - - @method_decorator(login_required) - def exam_list_all(self, request): - return self.exam_list(request, all=True) - - @method_decorator(login_required) - def exam_list(self, request, all=False): - if all: - exams = self.Exam.objects.all().order_by("name") - else: - exams = self.Exam.objects.filter(exam_mode=True, archive=False).order_by( - "name" - ) - - if not self.check_user_access(request.user): - raise PermissionDenied - - marking = False - - if self.app_name in ("rapids", "anatomy", "longs"): - marking = True - - return render( - request, - "exam_list.html".format(self.app_name), - { - "exams": exams, - "app_name": self.app_name, - "view_all": all, - "marking": marking, - }, - ) - - @method_decorator(login_required) - def exam_overview(self, request, pk): - # print(Exam.objects.all()) - # exams = Exam.objects.all() - # print("test", Exam.objects.all().get(id=pk)) - exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=pk) - - if request.user not in exam.author.all(): - if not self.check_user_access(request.user, pk): - raise PermissionDenied - - can_edit = ( - self.check_user_edit_access(request.user, pk) | request.user.is_superuser - ) - - notes = [] - if can_edit: - notes = self.exam_notes(request, pk) - - if self.app_name == "anatomy": - questions = ( - exam.exam_questions.select_related( - "modality", - "structure", - "body_part", - "region", - "examination", - "question_type", - ) - .all() - .prefetch_related( - Prefetch( - "answers", - queryset=self.Answer.objects.filter( - proposed=False, status=self.Answer.MarkOptions.CORRECT - ), - to_attr="prefetched_primary_answer" - # queryset=self.Answer.objects.filter(), - ), - ) - ) - elif self.app_name == "rapids": - questions = ( - exam.exam_questions.select_related() - .all() - .prefetch_related("images", "abnormality", "region", "examination") - ) - elif self.app_name == "physics": - questions = ( - exam.exam_questions.select_related("category").all() - # .prefetch_related("images", "abnormality", "region", "examination") - ) - else: - questions = exam.exam_questions.select_related().all() - - question_number = len(questions) - - return render( - request, - "{}/exam_overview.html".format(self.app_name), - { - "exam": exam, - "questions": questions, - "question_number": question_number, - "can_edit": can_edit, - "notes": notes, - "app_name": self.app_name, - }, - ) - - @method_decorator(login_required) - def exam_scores_refresh(self, request, pk): - exam = get_object_or_404(self.Exam, pk=pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - questions = exam.exam_questions.all() - - cids = self.CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) - - # Force a score update - for c in cids: - c.get_answer_score(cached=False) - - return render( - request, - f"{self.app_name}/base.html", - { - "simple_content": format_html("""Answer scores updated
- Return""", reverse(f"{self.app_name}:exam_scores_cid", kwargs={"pk": exam.pk})) - }, - ) - - def exam_cids(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(): - raise PermissionDenied - - if request.user not in exam.author.all(): - if not self.check_user_access(request.user, exam_id): - raise PermissionDenied - - cid_users = exam.valid_users.all() - - cid_user_count = cid_users.count() - - return render( - request, - "exam_cids.html", - { - "exam": exam, - "cid_users": cid_users, - "cid_user_count": cid_user_count, - "app_name": self.app_name, - }, - ) - - # @method_decorator(login_required) - def exam_notes(self, request, pk): - exam = get_object_or_404(self.Exam, pk=pk) - - if request.user not in exam.author.all(): - if not self.check_user_access(request.user, pk): - raise PermissionDenied - - q, content_type = get_question_and_content_type(self.question_type) - - question_pks = exam.exam_questions.values_list("pk", flat=True) - - # Get active notes for type - notes = QuestionNote.objects.filter( - content_type=content_type, object_id__in=question_pks, complete=False - ) - - return notes - - @method_decorator(login_required) - def exam_json_edit(self, request, pk): - if request.is_ajax() and request.method == "POST": - - if not self.check_user_edit_access(request.user, exam_id=pk): - data = {"status": "invalid permisions"} - return JsonResponse(data, status=400) - - exam = get_object_or_404(self.Exam, pk=pk) - - if "add_exam_questions" in request.POST: - question_ids = json.loads(request.POST.get("add_exam_questions")) - question_objects = self.Question.objects.filter(pk__in=question_ids) - - exam.exam_questions.add(question_objects) - exam.save() - data = {"status": "success"} - return JsonResponse(data, status=200) - - if "set_exam_order" in request.POST: - new_order = json.loads(request.POST.get("set_exam_order")) - - preserved = Case( - *[When(pk=pk, then=pos) for pos, pk in enumerate(new_order)] - ) - objects = self.Question.objects.filter(pk__in=new_order).order_by( - preserved - ) - - # We now allow deleting exam questions - # if len(objects) != exam.exam_questions.count(): - # data = {"status": "error, count does not match"} - # return JsonResponse(data, status=400) - - exam.exam_questions.set(objects) - exam.save() - data = {"status": "success"} - return JsonResponse(data, status=200) - - if not self.check_user_access(request.user, pk): - raise PermissionDenied - - if "set_open_access" in request.POST: - if request.POST.get("set_open_access") == "true": - state = True - else: - state = False - - questions_changed = [] - for q in exam.exam_questions.all(): - q.open_access = state - q.save() - questions_changed.append(q.pk) - - data = { - "status": "success", - "questions": questions_changed, - "state": state, - } - return JsonResponse(data, status=200) - - data = {"status": "error, unknown request"} - return JsonResponse(data, status=400) - else: - data = {"status": "error"} - return JsonResponse(data, status=400) - - @method_decorator(login_required) - def mark_overview(self, request, pk): - exam = get_object_or_404(self.Exam, pk=pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - if request.user not in exam.author.all(): - if not self.check_user_access(request.user, pk): - raise PermissionDenied - - if self.app_name == "anatomy": - questions = ( - exam.exam_questions.select_related( - "modality", - "structure", - "body_part", - "region", - "examination", - "question_type", - ) - .all() - .prefetch_related( - # "cid_user_answers", - Prefetch( - "answers", - queryset=self.Answer.objects.filter( - proposed=False, status=self.Answer.MarkOptions.CORRECT - ), - to_attr="prefetched_primary_answer" - # queryset=self.Answer.objects.filter(), - ), - # Prefetch( - # "cid_user_answers", - # queryset=self.CidUserAnswer.objects.filter(score=self.Answer.MarkOptions.UNMARKED, exam__id=pk), - # to_attr="prefetched_unmarked_cid_answers" - # #queryset=self.Answer.objects.filter(), - # ), - ) - ) - else: - questions = exam.exam_questions.all() - - # Handle exams that require double marking - try: - if exam.double_mark: - question_unmarked_map = [] - for q in questions: - question_unmarked_map.append( - ( - q, - int(q.get_unmarked_user_answer_count(exam_pk=exam.pk)), - int( - q.get_unmarked_user_answer_count( - exam_pk=exam.pk, marker=request.user - ) - ), - ) - ) - - return render( - request, - "{}/mark_overview.html".format(self.app_name), - {"exam": exam, "question_unmarked_map": question_unmarked_map}, - ) - except AttributeError: - pass - - question_unmarked_map = [] - for q in questions: - count = int(q.get_unmarked_user_answer_count(exam_pk=exam.pk)) - question_unmarked_map.append( - (q, count, count) - ) - - return render( - request, - "{}/mark_overview.html".format(self.app_name), - {"exam": exam, "question_unmarked_map": question_unmarked_map}, - ) - - @method_decorator(login_required) - def index(self, request): - exams = self.Exam.objects.filter(author__id=request.user.id) - - if ( - self.app_name == "rapids" - and request.user.groups.filter(name="rapid_checker").exists() - ): - exams = self.Exam.objects.all() - if ( - self.app_name == "anatomy" - and request.user.groups.filter(name="anatomy_checker").exists() - ): - exams = self.Exam.objects.all() - if ( - self.app_name == "longs" - and request.user.groups.filter(name="long_checker").exists() - ): - exams = self.Exam.objects.all() - - return render(request, "{}/index.html".format(self.app_name), {"exams": exams}) - - @method_decorator(login_required) - def exam_question_detail(self, request, pk, sk): - exam = get_object_or_404(self.Exam, pk=pk) - - if request.user not in exam.author.all(): - if not self.check_user_access(request.user, pk): - raise PermissionDenied - - question = exam.exam_questions.all()[sk] - - exam_length = len(exam.exam_questions.all()) - - pos = exam.get_question_index(question) + 1 - - previous = -1 - if sk > 0: - previous = sk - 1 - next = sk + 1 - if sk == exam_length - 1: - next = False - - return render( - request, - "{}/question_detail.html".format(self.app_name), - { - "question": question, - "exam": exam, - "next": next, - "previous": previous, - "exam_length": exam_length, - "pos": pos, - }, - ) - - def active_exams(self, request, json=True, based=True, cid=None, passcode=None): - exams = self.Exam.objects.filter(archive=False) - - active_exams = {"exams": []} - - if cid is not None: - cid_user = CidUser.objects.filter(cid=cid).first() - if not cid_user or cid_user.passcode != passcode: - if json == False: - return active_exams["exams"] - return JsonResponse({"status": "invalid"}, status=401) - - for exam in exams: - if exam.active or self.check_user_access(request.user, exam.pk): - print(exam.name, cid, passcode) - if exam.exam_mode and not exam.check_cid_user(cid, passcode): - print(exam.name, "fail") - continue - - if exam.json_creation_time: - creation_time = exam.json_creation_time.isoformat() - else: - creation_time = "None" - - url = request.build_absolute_uri(exam.get_json_url()) - # hacky - if not based: - url = url + "/unbased" - - obj = { - "name": exam.get_exam_name(), - "url": url, - "type": self.question_type, - "eid": "{}/{}".format(self.question_type, exam.pk), - "json_creation_time": creation_time, - "exam_json_id": exam.exam_json_id, - "exam_active": exam.active, - "exam_mode": exam.exam_mode, - } - - if self.question_type == "long": - h = {} - for question in exam.exam_questions.all(): - # Generate json if needed - if question.json_creation_time is None: - question.get_question_json() - h[question.pk] = question.question_json_id - obj["multi_question_json"] = h - active_exams["exams"].append(obj) - - if json == False: - return active_exams["exams"] - - return JsonResponse(active_exams) - - @method_decorator(csrf_exempt) - def postExamAnswers(self, request): - if request.is_ajax and request.method == "POST": - - n = 0 - - for answer in json.loads(request.POST.get("answers")): - ret_status, ret = self.loadJsonAnswer(answer) - # try: - # ret_status, ret = self.loadJsonAnswer(answer) - # except: - # # We catch the error here so users get an error (rather than 500) - # return JsonResponse({"success": False, "error": "Invalid data"}) - - # if ret is not True: - # return ret - - if ret_status: - n = n + 1 - else: - return ret - - # print(UserAnswer.objects.filter(exam__id=q["eid"])) - # print(request.urlencode()) - - exam_type, exam_id = request.POST.get("eid").split("/") - - c = CidUser.objects.filter(cid=request.POST.get("cid")).first() - exam = self.Exam.objects.get(pk=exam_id) - t = timezone.make_aware( - datetime.fromtimestamp(float(request.POST.get("start_time"))) - ) - cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c, start_time=t) - - cid_user_exam.end_time = timezone.now() - cid_user_exam.save() - - return JsonResponse({"success": True, "question_count": n}) - return JsonResponse({"success": False, "error": "Invalid data"}) - - def exam_json_unbased(self, request, pk): - """ - No (file based) caching is enabled for unbased exams - """ - exam = get_object_or_404(self.Exam, pk=pk) - - if not exam.active and not self.check_user_access(request.user, pk): - raise Http404("No available exam") - - exam_json = exam.get_exam_json(based=False) - # exam_json["exam_active"] = False - return JsonResponse(exam_json) - - def exam_json(self, request, pk): - - exam = get_object_or_404(self.Exam, pk=pk) - - if not exam.active and not self.check_user_access(request.user, pk): - raise Http404("No available exam") - - # exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk)) - - path = "{0}{1}/exam/{2}.json".format(settings.MEDIA_ROOT, self.app_name, pk) - url = "{0}{1}/exam/{2}.json".format(settings.MEDIA_URL, self.app_name, pk) - - if os.path.isfile(path) and not exam.recreate_json: - # exam_json_cache["cached"] = True - # Make sure we pass on an argument if we are forcing a reload - if request.GET.get("_"): - query_string = urllib.parse.urlencode({"_": request.GET.get("_")}) - url = "{}?{}".format(url, query_string) - return redirect(url) - return redirect(url) - return JsonResponse(exam_json_cache) - - time = datetime.now() - - exam.exam_json_id += 1 - - with open(path, "w+") as f: - exam_json = exam.get_exam_json() - exam_json["generated"] = time.isoformat() - exam_json["exam_json_id"] = exam.exam_json_id - f.write(json.dumps(exam_json)) - - exam.recreate_json = False - - exam.json_creation_time = time - exam.save() - - # We also try to clear the cache of any associated questions (longs) - # see exam_question_json - # n = exam.exam_questions.count() - # question_keys = ["{}_exam_json_{}_{}".format(self.app_name, pk, i) for i in range(1, n)] - # cache.delete_many(question_keys) - - # cache.set("{}_exam_json_{}".format(self.app_name, pk), exam_json, 3600) - - return JsonResponse(exam_json) - - def exam_question_json(self, request, pk, sk): - question = get_object_or_404(self.Question, pk=sk) - exam = get_object_or_404(self.Exam, pk=pk) - - if not exam.active and not self.check_user_access(request.user, pk): - raise Http404("No available exam") - - if request.GET.get("_"): - base_url = redirect("{}:question_json".format(self.app_name), pk=sk) - query_string = urllib.parse.urlencode({"_": request.GET.get("_")}) - url = "{}?{}".format(base_url, query_string) - return redirect(url) - - return redirect("{}:question_json".format(self.app_name), pk=sk) - - def exam_question_json_unbased(self, request, pk, sk): - question = get_object_or_404(self.Question, pk=sk) - exam = get_object_or_404(self.Exam, pk=pk) - - if not exam.active and not self.check_user_access(request.user, pk): - raise Http404("No available exam") - - return redirect("{}:question_json_unbased".format(self.app_name), pk=sk) - redirect() - - question_json_cache = cache.get("{}_question_json_{}".format(self.app_name, sk)) - - if question_json_cache is not None and not question.recreate_json: - question_json_cache["cached"] = True - return JsonResponse(question_json_cache) - - question_json = exam.get_exam_question_json(sk) - - cache.set("{}_question_json_{}".format(self.app_name, sk), question_json, 3600) - - return JsonResponse(question_json) - - def exam_scores_cid(self, request, pk): - exam = get_object_or_404(self.Exam, pk=pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - user_answers_and_marks = defaultdict(list) - user_answers_marks = defaultdict(list) - user_answers = defaultdict(list) - user_names = {} - # cid_passcodes = {} - - by_question = defaultdict(dict) - unmarked = set() - - questions = exam.exam_questions.all() - - # We could prefect the CidUserAnswers.answers here (if we didn't cache them) - cid_user_answers = self.CidUserAnswer.objects.select_related("question").filter( - question__in=questions, exam__id=pk - ) - - cids = set() - - # Loop through all candidates - for cid_user_answer in cid_user_answers: - # Convoluted (probably...) - cid = cid_user_answer.cid - # cid_passcodes[cid] = cid_user_answer.passcode - cids.add(cid) - s = cid_user_answer - user_names[cid] = cid - - q = cid_user_answer.question - - # if not s: - # # skip if no answer - # user_answers_marks[cid].append(0) - # user_answers[cid].append("") - # by_question[q].append(("", 0)) - # continue - - ans = s.get_answer_string() - - answer_score = s.get_answer_score() - if answer_score == "unmarked": - index = exam.get_question_index(q) - unmarked.add(index) - user_answers[cid].append(ans) - user_answers_marks[cid].append(answer_score) - user_answers_and_marks[cid].append((ans, answer_score)) - - if self.app_name in ("rapids", "anatomy", "sbas"): - by_question[q][cid] = (ans, answer_score) - else: - zipped_ans_scores = zip(ans, answer_score) - by_question[q][cid] = zipped_ans_scores - - user_scores = {} - user_scores_normalised = {} - for user in user_answers_marks: - - if self.app_name in ("rapids", "anatomy", "sbas"): - user_scores[user] = sum( - [i for i in user_answers_marks[user] if i != "unmarked"] - ) - else: - user_scores[user] = sum( - [sum(i) for i in user_answers_marks[user] if i != "unmarked"] - ) - - if self.app_name == "rapids": - user_scores_normalised[user] = normaliseRapidsScore( - sum([i for i in user_answers_marks[user] if i != "unmarked"]) - ) - else: - user_scores_normalised[user] = user_scores[user] - - # ignore scores of 0 for stats - user_scores_list = [i for i in user_scores.values() if i > 0] - - if self.app_name in ("rapids", "anatomy"): - max_score = len(questions) * 2 - elif self.app_name == "physics": - max_score = len(questions) * 5 - else: - max_score = len(questions) - - if len(user_scores_list) < 1: - mean = 0 - median = 0 - mode = 0 - fig_html = "" - else: - mean = statistics.mean(user_scores_list) - median = statistics.median(user_scores_list) - try: - mode = statistics.mode(user_scores_list) - except statistics.StatisticsError: - mode = "No unique mode" - - df = user_scores_list - fig = px.histogram( - df, - x=0, - title="{}: distribution of scores".format(exam), - labels={"0": "Score"}, - height=400, - width=600, - ) - fig_html = fig.to_html() - - exam.stats_mean = mean - exam.stats_median = median - exam.stats_mode = mode - - exam.stats_candidates = len(user_scores_list) - exam.stats_max_possible = max_score - - exam.stats_min = min(user_scores_list) - exam.stats_max = max(user_scores_list) - - exam.stats_graph = fig_html - - exam.user_scores = user_scores - - exam.save() - - return render( - request, - f"{self.app_name}/exam_scores_new.html", - { - "cids": sorted(cids), - # "cid_passcodes": cid_passcodes, - "exam": exam, - "unmarked": unmarked, - "questions": questions, - "by_question": by_question, - "user_answers": dict(user_answers), - "user_answers_marks": dict(user_answers_marks), - "user_scores": user_scores, - "user_scores_normalised": user_scores_normalised, - "user_scores_list": user_scores_list, - "user_names": user_names, - "user_answers_and_marks": user_answers_and_marks, - "max_score": max_score, - "mean": mean, - "median": median, - "mode": mode, - "plot": fig_html, - }, - ) - - -class GenericViewBase: - def __init__(self, app_name, QuestionObject, CidUserAnswerObject, ExamObject): - self.question_object = QuestionObject - self.cid_user_answer_object = CidUserAnswerObject - self.exam_object = ExamObject - self.app_name = app_name - - g = { - "rapids": "rapid_checker", - "anatomy": "anatomy_checker", - "longs": "longs_checker", - "sbas": "sbas_checker", - "physics": "physics_checker", - } - - self.checker_group = g[app_name] - - def question_review(self, request, pk): - """ - Return a json representation of the question when the exam is active - """ - if request.is_ajax(): - exam = get_object_or_404(self.exam_object, pk=pk) - - print(request.POST["question_number"]) - print(type(request.POST["question_number"])) - - if not exam.publish_results: - raise Http404 - - question = exam.exam_questions.all()[int(request.POST["question_number"])] - - question_json = question.get_question_json(based=False) - return JsonResponse(question_json) - return JsonResponse({"status": "error"}) - - @method_decorator(user_passes_test(lambda u: u.is_superuser)) - def user_answer_delete_multiple(self, request): - if request.is_ajax(): - print(request.POST["answer_ids"]) - answer_ids = json.loads(request.POST["answer_ids"]) - - # We could probably delete them all at once.... - for id in answer_ids: - self.cid_user_answer_object.objects.get(pk=id).delete() - return JsonResponse({"status": "success"}) - return JsonResponse({"status": "error"}) - - @method_decorator(login_required) - def question_detail(self, request, pk): - question = get_object_or_404(self.question_object, pk=pk) - - if not question.open_access: - if ( - not request.user.groups.filter(name=self.checker_group).exists() - and request.user not in question.author.all() - ): - raise PermissionDenied() - # if request.user not in rapid.author.all(): - # raise PermissionDenied - - view_feedback = False - if ( - request.user.groups.filter(name=self.checker_group).exists() - or request.user.groups.filter(name="feedback_checker").exists() - or request.user in question.author.all() - ): - view_feedback = True - - # logging.debug(rapid.subspecialty.first().name.all()) - return render( - request, - f"{self.app_name}/question_detail.html", - {"question": question, "view_feedback": view_feedback}, - ) - - @method_decorator(login_required) - def question_user_answers(self, request, pk): - question = get_object_or_404(self.question_object, pk=pk) - - answers = self.cid_user_answer_object.objects.filter(question__id=question.pk) - - return render( - request, - f"{self.app_name}/question_user_answers.html", - {"question": question, "answers": answers}, - ) - - @method_decorator(login_required) - def author_detail(self, request, pk): - # logging.debug(Author.objects.all()) - # author = get_object_or_404(Author, pk=pk) - author = User.objects.get(pk=pk) - - questions = self.question_object.objects.filter(author=pk) - - return render( - request, - f"{self.app_name}/category_detail.html", - {"category": author, "questions": questions}, - ) - - @method_decorator(login_required) - def author_list(self, request): - authors = User.objects.all() - - return render( - request, f"{self.app_name}/author_list.html", {"authors": authors} - ) - - -class ExamCloneMixin: - def get_initial(self): - old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"]) - initial_data = model_to_dict(old_object, exclude=["id"]) - - questions = old_object.exam_questions.all().values_list("id", flat=True) - - self.exam_questions = list(questions) - - return initial_data - - def form_valid(self, form): - object = form.save() - object.exam_questions.set(self.exam_questions) - object.save() - return HttpResponseRedirect(object.get_absolute_url()) - - -class ExaminationAutocomplete(autocomplete.Select2QuerySetView): - def get_queryset(self): - # Don't forget to filter out results depending on the visitor ! - if not self.request.user.is_authenticated: - return Examination.objects.none() - - qs = Examination.objects.all() - - if self.q: - # This raises a fielderror which breaks creating a new item if not caught - try: - qs = qs.filter(examination__icontains=self.q) - except FieldError: - return Examination.objects.none() - - return qs - -class CidUserExamView(CidManagerRequiredMixin, SingleTableMixin, FilterView): - model = CidUser - table_class = CidUserExamTable - template_name = "generic/cid_view.html" - - filterset_class = CidUserFilter - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - # user = self.request.user - - filters = {"archive": False, "exam_mode": True} - - physics_exams = [(i.name, i.pk) for i in PhysicsExam.objects.filter(**filters)] - rapid_exams = [(i.name, i.pk) for i in RapidExam.objects.filter(**filters)] - sba_exams = [(i.name, i.pk) for i in SbasExam.objects.filter(**filters)] - longs_exams = [(i.name, i.pk) for i in LongExam.objects.filter(**filters)] - anatomy_exams = [(i.name, i.pk) for i in AnatomyExam.objects.filter(**filters)] - - cid_user_groups = [ - (i.name, i.pk) for i in CidUserGroup.objects.filter(archive=False) - ] - - context["physics_exams"] = physics_exams - context["rapid_exams"] = rapid_exams - context["sba_exams"] = sba_exams - context["longs_exams"] = longs_exams - context["anatomy_exams"] = anatomy_exams - context["cid_user_groups"] = cid_user_groups - context["exams"] = True - return context - -class CidUserView(CidManagerRequiredMixin, SingleTableMixin, FilterView): - model = CidUser - table_class = CidUserTable - template_name = "generic/cid_view.html" - - filterset_class = CidUserFilter - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - # user = self.request.user - - filters = {"archive": False, "exam_mode": True} - - physics_exams = [(i.name, i.pk) for i in PhysicsExam.objects.filter(**filters)] - rapid_exams = [(i.name, i.pk) for i in RapidExam.objects.filter(**filters)] - sba_exams = [(i.name, i.pk) for i in SbasExam.objects.filter(**filters)] - longs_exams = [(i.name, i.pk) for i in LongExam.objects.filter(**filters)] - anatomy_exams = [(i.name, i.pk) for i in AnatomyExam.objects.filter(**filters)] - - cid_user_groups = [ - (i.name, i.pk) for i in CidUserGroup.objects.filter(archive=False) - ] - - context["physics_exams"] = physics_exams - context["rapid_exams"] = rapid_exams - context["sba_exams"] = sba_exams - context["longs_exams"] = longs_exams - context["anatomy_exams"] = anatomy_exams - context["cid_user_groups"] = cid_user_groups - - cid_user_groups = [ - (i.name, i.pk) for i in CidUserGroup.objects.filter(archive=False) - ] - - context["cid_user_groups"] = cid_user_groups - return context - - -@login_required -@user_is_cid_user_manager -def group_view(request): - groups = CidUserGroup.objects.filter(archive=False) - - return render( - request, - "generic/group_view.html", - {"groups": groups}, - ) - -@user_is_cid_user_manager -def group_view(request): - groups = CidUserGroup.objects.filter(archive=False) - - return render( - request, - "generic/group_view.html", - {"groups": groups, "view_all": False}, - ) - -@user_is_cid_user_manager -def group_view_all(request): - groups = CidUserGroup.objects.filter() - - return render( - request, - "generic/group_view.html", - {"groups": groups, "view_all": True}, - ) - - -@login_required -@user_is_cid_user_manager -def group_email_resend(request, pk): - return group_email(request, pk, resend=True) - -@login_required -@user_is_cid_user_manager -def group_email_results_resend(request, pk): - return group_email_results(request, pk, resend=True) - - -@login_required -@user_is_cid_user_manager -def group_email(request, pk, resend=False): - group = get_object_or_404(CidUserGroup, pk=pk) - - if resend: - users = group.ciduser_set.filter(active=True) - else: - users = group.ciduser_set.filter(active=True, login_email_sent=False) - - return render( - request, - "generic/group_emailed.html", - #{"sent": sent, "not_sent": not_sent, "users_count": users.count()}, - {"users": users, "users_count": users.count()}, - ) - - #sent = [] - #not_sent = [] - #for u in users: - # success, msg = u.email_details(resend=resend) - # if success: - # sent.append(u) - # else: - # not_sent.append((u, msg)) - - #return render( - # request, - # "generic/group_emailed.html", - # {"sent": sent, "not_sent": not_sent, "users_count": users.count()}, - #) - - -@user_is_cid_user_manager -def group_email_results(request, pk, resend=False): - group = get_object_or_404(CidUserGroup, pk=pk) - - if resend: - users = group.ciduser_set.filter(active=True, internal_candidate=True) - else: - users = group.ciduser_set.filter(active=True, internal_candidate=True, results_email_sent=False) - - #sent = [] - #not_sent = [] - #for u in users: - # success, msg = u.email_results(resend=resend) - # if success: - # sent.append(u) - # else: - # not_sent.append((u, msg)) - - return render( - request, - "generic/group_emailed.html", - #{"sent": sent, "not_sent": not_sent, "users_count": users.count()}, - {"users": users, "users_count": users.count()}, - ) - -@user_is_cid_user_manager -def candidate_email_results(request, cid, resend=False): - user = CidUser.objects.get(cid=cid) - - email_sent, comment = user.email_results(additional_emails=["priya.suresh@nhs.net"], resend=True) - - return JsonResponse({"sent": email_sent, "comment": comment}) - -@user_is_cid_user_manager -def candidate_email_results_resend(request, cid, resend=True): - return candidate_email_results(request, cid, resend=True) - - -@user_is_cid_user_manager -def manage_cid_users(request): - if request.is_ajax() and request.method == "POST": - physics_exams = json.loads(request.POST.get("physics_exams")) - rapid_exams = json.loads(request.POST.get("rapid_exams")) - sba_exams = json.loads(request.POST.get("sba_exams")) - longs_exams = json.loads(request.POST.get("longs_exams")) - anatomy_exams = json.loads(request.POST.get("anatomy_exams")) - cid_groups = json.loads(request.POST.get("cid_groups")) - - selected_cids = json.loads(request.POST.get("selected_cids")) - - emails = json.loads(request.POST.get("emails")) - - number_to_create = int(request.POST.get("number_to_create")) - - add_group = request.POST.get("add_group") - delete = request.POST.get("delete") - activate = request.POST.get("activate") - deactivate = request.POST.get("deactivate") - add_exams = request.POST.get("add_exams") - clear_exams = request.POST.get("clear_exams") - toggle_internal = request.POST.get("toggle_internal") - - if add_group == "true": - cids = CidUser.objects.filter(pk__in=selected_cids) - for c in cids: - c.group_id = cid_groups[0] - c.save() - return JsonResponse({"status": "success"}) - - if delete == "true": - cids = CidUser.objects.filter(pk__in=selected_cids).delete() - return JsonResponse({"status": "success"}) - - if activate == "true": - cids = CidUser.objects.filter(pk__in=selected_cids) - for c in cids: - c.active = True - c.save() - return JsonResponse({"status": "success"}) - - if toggle_internal == "true": - cids = CidUser.objects.filter(pk__in=selected_cids) - for c in cids: - c.internal_candidate = not c.internal_candidate - c.save() - return JsonResponse({"status": "success"}) - - if deactivate == "true": - cids = CidUser.objects.filter(pk__in=selected_cids) - for c in cids: - c.active = False - c.save() - return JsonResponse({"status": "success"}) - - if clear_exams == "true": - cids = CidUser.objects.filter(pk__in=selected_cids) - for c in cids: - c.physics_exams.clear() - c.rapid_exams.clear() - c.sba_exams.clear() - c.longs_exams.clear() - c.anatomy_exams.clear() - return JsonResponse({"status": "success"}) - - if add_exams == "true": - cids = CidUser.objects.filter(pk__in=selected_cids) - for c in cids: - if physics_exams: - c.physics_exams.set(physics_exams) - c.save() - if rapid_exams: - c.rapid_exams.set(rapid_exams) - c.save() - if sba_exams: - c.sba_exams.set(sba_exams) - c.save() - if longs_exams: - c.longs_exams.set(longs_exams) - c.save() - if anatomy_exams: - c.anatomy_exams.set(anatomy_exams) - c.save() - return JsonResponse({"status": "success"}) - - try: - new_cid = CidUser.objects.all().order_by("-cid")[0].cid + 1 - except IndexError: - new_cid = 10000 - - if emails: - email_list = [i.strip() for i in emails.split()] - for e in email_list: - passcode = "".join(random.choices(string.ascii_uppercase, k=4)) - c = CidUser(cid=new_cid, passcode=passcode, email=e) - c.save() - if cid_groups: - c.group_id = cid_groups[0] - - if physics_exams: - c.physics_exams.set(physics_exams) - c.save() - if rapid_exams: - c.rapid_exams.set(rapid_exams) - c.save() - if sba_exams: - c.sba_exams.set(sba_exams) - c.save() - if longs_exams: - c.longs_exams.set(longs_exams) - c.save() - if anatomy_exams: - c.anatomy_exams.set(anatomy_exams) - c.save() - new_cid = new_cid + 1 - - for n in range(number_to_create): - passcode = "".join(random.choices(string.ascii_uppercase, k=4)) - c = CidUser(cid=new_cid, passcode=passcode) - c.save() - if cid_groups: - c.group_id = cid_groups[0] - - if physics_exams: - c.physics_exams.set(physics_exams) - c.save() - if rapid_exams: - c.rapid_exams.set(rapid_exams) - c.save() - if sba_exams: - c.sba_exams.set(sba_exams) - c.save() - if longs_exams: - c.longs_exams.set(longs_exams) - c.save() - if anatomy_exams: - c.anatomy_exams.set(anatomy_exams) - c.save() - new_cid = new_cid + 1 - - return JsonResponse({"status": "success"}) - return JsonResponse({"status": "fail"}) - - -class CidUserCreate(RevisionMixin, CidManagerRequiredMixin, CreateView): - model = CidUser - form_class = CidUserForm - -class CidUserUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView): - model = CidUser - form_class = CidUserForm - -class CidUserGroupCreate(RevisionMixin, CidManagerRequiredMixin, CreateView): - model = CidUserGroup - form_class = CidUserGroupForm - -class CidUserGroupUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView): - model = CidUserGroup - form_class = CidUserGroupForm +from collections import defaultdict +import statistics +from dal import autocomplete +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.forms.models import model_to_dict +from django.shortcuts import render, get_object_or_404, redirect + +from django.contrib.auth.decorators import login_required, user_passes_test +from django.urls.base import reverse +from django.utils import timezone +from django.utils.html import format_html +from django.views.decorators.csrf import csrf_exempt +from django.core.exceptions import PermissionDenied, FieldError + +from django.http import Http404, JsonResponse +from django.http import HttpResponseRedirect, HttpResponse + +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin + + +from django.utils.decorators import method_decorator + + +from django.core.cache import cache + +import json +import urllib + +from django.views import View +from django.views.generic.edit import CreateView, UpdateView +from django_filters.views import FilterView +from django_tables2.views import SingleTableMixin +from reversion.views import RevisionMixin +from generic.decorators import user_is_cid_user_manager +from generic.filters import CidUserFilter + +from generic.tables import CidUserExamTable, CidUserTable + +from .forms import ( + CidUserForm, + ExaminationForm, + CidUserGroupForm, +) + +from .models import CidUser, CidUserGroup, Examination, QuestionNote + +from rapids.models import Rapid as RapidQuestion +from rapids.models import Exam as RapidExam +from longs.models import Long as LongQuestion +from longs.models import Exam as LongExam +from anatomy.models import AnatomyQuestion as AnatomyQuestion +from anatomy.models import Exam as AnatomyExam +from sbas.models import Question as SbasQuestion +from sbas.models import Exam as SbasExam +from physics.models import Question as PhysicsQuestion +from physics.models import Exam as PhysicsExam + +from django.db.models import Case, When +from django.conf import settings + +from datetime import datetime +import os +import string +import random +import plotly.express as px + +# from rad.views import get_question_and_content_type +from django.db.models import Prefetch + + +class CidManagerRequiredMixin(UserPassesTestMixin): + def test_func(self): + return self.request.user.groups.filter(name="cid_user_manager").exists() + + +# def get_object(self, *args, **kwargs): +# obj = super().get_object(*args, **kwargs) +# if self.request.user.groups.filter(name="cid_user_manager").exists(): +# return obj +# raise PermissionDenied() # or Http404 + + +def normaliseRapidsScore(score): + if score == 49: + return 4.5 + elif score in [50, 51]: + return 5 + elif score in [52, 53]: + return 5.5 + elif score in [54]: + return 6 + elif score in [55, 56]: + return 6.5 + elif score in [57, 58]: + return 7 + elif score in [59]: + return 7.5 + elif score in [60]: + return 8 + + return 4 + + +def get_question_and_content_type(question_type): + if question_type == "rapid": + question = RapidQuestion + content_type = ContentType.objects.get(model="rapid") + elif question_type == "anatomy": + question = AnatomyQuestion + content_type = ContentType.objects.get(model="anatomyquestion") + elif question_type == "long": + question = LongQuestion + content_type = ContentType.objects.get(model="long") + elif question_type == "sbas": + question = SbasQuestion + content_type = ContentType.objects.get(app_label="sbas", model="question") + elif question_type == "physics": + question = PhysicsQuestion + content_type = ContentType.objects.get(app_label="physics", model="question") + else: + raise PermissionError() + + return question, content_type + + +# Create your views here. +@login_required +def create_examination(request): + form = ExaminationForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "rapids/create_simple.html", {"form": form, "name": "Examination"} + ) + + +@csrf_exempt +def get_examination_id(request): + if request.is_ajax(): + examination_name = request.GET["examination_name"] + examination_id = Examination.objects.get(name=examination_name).id + data = { + "examination_id": examination_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +# Generic view to dispatch to indivuals app views +@login_required +def generic_exam_json_edit(request): + if request.is_ajax() and request.method == "POST": + question_type = request.POST.get("type") + + if question_type == "rapid": + Exam = RapidExam + Question = RapidQuestion + elif question_type == "long": + Exam = LongExam + Question = LongQuestion + elif question_type == "anatomy": + Exam = AnatomyExam + Question = AnatomyQuestion + elif question_type == "physics": + Exam = PhysicsExam + Question = PhysicsQuestion + elif question_type == "sbas": + Exam = SbasExam + Question = SbasQuestion + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + exam = get_object_or_404(Exam, pk=request.POST.get("exam_id")) + + # if "exam_toggle_active" in request.POST: + # active = json.loads(request.POST.get("active")) + + # data = {"status": "success"} + # return JsonResponse(data, status=200) + + if "add_exam_questions" in request.POST: + question_ids = json.loads(request.POST.get("add_exam_questions")) + # question_objects = Question.objects.filter(pk__in=question_ids) + + add_count = 0 + for i in question_ids: + # We get an integrity error if we try to add an object that already exists + if not exam.exam_questions.filter(pk=i).exists(): + add_count += 1 + exam.exam_questions.add(i) + + if add_count > 0: + exam.save() + data = {"status": "success", "questions added": add_count} + return JsonResponse(data, status=200) + + if "set_exam_order" in request.POST: + new_order = json.loads(request.POST.get("set_exam_order")) + + preserved = Case( + *[When(pk=pk, then=pos) for pos, pk in enumerate(new_order)] + ) + objects = Question.objects.filter(pk__in=new_order).order_by(preserved) + + if len(objects) != exam.exam_questions.count(): + data = {"status": "error, count does not match"} + return JsonResponse(data, status=400) + + exam.exam_questions.set(objects) + exam.save() + data = {"status": "success"} + return JsonResponse(data, status=200) + + # Only checkers can do the following + if ( + question_type == "rapid" + and not request.user.groups.filter(name="rapid_checker").exists() + ): + data = {"status": "error, permission denied"} + return JsonResponse(data, status=400) + if ( + question_type == "anatomy" + and not request.user.groups.filter(name="anatomy_checker").exists() + ): + data = {"status": "error, permission denied"} + return JsonResponse(data, status=400) + if ( + question_type == "long" + and not request.user.groups.filter(name="long_checker").exists() + ): + data = {"status": "error, permission denied"} + return JsonResponse(data, status=400) + + if "set_open_access" in request.POST: + if request.POST.get("set_open_access") == "true": + state = True + else: + state = False + + questions_changed = [] + for q in exam.exam_questions.all(): + q.open_access = state + q.save() + questions_changed.append(q.pk) + + data = {"status": "success", "questions": questions_changed, "state": state} + return JsonResponse(data, status=200) + + data = {"status": "error, unknown request"} + return JsonResponse(data, status=400) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + +class ExamViews(View, LoginRequiredMixin): + def __init__( + self, + exam, + question, + answer, + cid_user_answer, + app, + question_type, + loadJsonAnswer, + ): + self.Exam = exam + self.Question = question + self.Answer = answer + self.CidUserAnswer = cid_user_answer + self.app_name = app + self.question_type = question_type + self.loadJsonAnswer = loadJsonAnswer + + # THis may be better than check_user_access below + # group_map = {"rapids" : "rapid_checker", "anatomy" : "anatomy_checker", "longs":"long_checker"} + # exam_group = group_map[self.app_name] + + def check_user_access(self, user, exam_id=None): + """Check if a user should be able to access a view + + Args: + user ([user]): user object + + Returns: + [boolean]: True if the user has access + """ + print("****", user, exam_id) + if exam_id is not None: + exam = get_object_or_404(self.Exam, pk=exam_id) + if (exam.open_access and exam.active) or user in exam.get_author_objects(): + return True + + if ( + self.app_name == "rapids" + and not user.groups.filter(name="rapid_checker").exists() + ): + return False + if ( + self.app_name == "anatomy" + and not user.groups.filter( + name__in=("anatomy_checker", "anatomy_marker") + ).exists() + ): + return False + if ( + self.app_name == "longs" + and not user.groups.filter( + name__in=("long_checker", "long_marker") + ).exists() + ): + return False + if ( + self.app_name == "physics" + and not user.groups.filter(name="physics_checker").exists() + ): + return False + if ( + self.app_name == "sbas" + and not user.groups.filter(name="sba_checker").exists() + ): + return False + return True + + def check_user_edit_access(self, user, exam_id=None): + """Check if a user should be able to access a view + + Args: + user ([user]): user object + + Returns: + [boolean]: True if the user has access + """ + # If a user is an exam author they should have acccess + if exam_id is not None: + exam = get_object_or_404(self.Exam, pk=exam_id) + if exam.open_access or user in exam.get_author_objects(): + return True + + if ( + self.app_name == "rapids" + and not user.groups.filter(name="rapid_checker").exists() + ): + return False + if ( + self.app_name == "anatomy" + and not user.groups.filter(name="anatomy_checker").exists() + ): + return False + if ( + self.app_name == "longs" + and not user.groups.filter(name__in=("long_checker",)).exists() + ): + return False + if ( + self.app_name == "physics" + and not user.groups.filter(name="physics_checker").exists() + ): + return False + if ( + self.app_name == "sbas" + and not user.groups.filter(name="sba_checker").exists() + ): + return False + return True + + @method_decorator(login_required) + def exam_toggle_results_published(self, request, pk): + if request.is_ajax() and request.method == "POST": + + if not self.check_user_edit_access(request.user): + data = {"status": "error, insufficient permission"} + return JsonResponse(data, status=400) + + exam = get_object_or_404(self.Exam, pk=pk) + + exam.publish_results = ( + True if request.POST.get("publish_results") == "true" else False + ) + exam.save() + data = { + "status": "success", + "publish_results": exam.publish_results, + "name": exam.name, + "id": exam.id, + } + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + @method_decorator(login_required) + def exam_toggle_active(self, request, pk): + if request.is_ajax() and request.method == "POST": + + if not self.check_user_edit_access(request.user): + data = {"status": "error, insufficient permission"} + return JsonResponse(data, status=400) + + exam = get_object_or_404(self.Exam, pk=pk) + + exam.active = True if request.POST.get("active") == "true" else False + exam.save() + data = { + "status": "success", + "active": exam.active, + "name": exam.name, + "id": exam.id, + } + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + @method_decorator(login_required) + def exam_json_recreate(self, request, pk): + exam = get_object_or_404(self.Exam, pk=pk) + + exam.recreate_json = True + exam.save() + + exam.get_exam_json() + + return redirect("{}:exam_overview".format(self.app_name), pk=pk) + + @method_decorator(login_required) + def question_json_recreate(self, request, pk): + question = get_object_or_404(self.Question, pk=pk) + + question.recreate_json = True + question.save() + + return redirect("{}:question_detail".format(self.app_name), pk=pk) + + @method_decorator(login_required) + def exam_list_all(self, request): + return self.exam_list(request, all=True) + + @method_decorator(login_required) + def exam_list(self, request, all=False): + if all: + exams = self.Exam.objects.all().order_by("name") + else: + exams = self.Exam.objects.filter(exam_mode=True, archive=False).order_by( + "name" + ) + + if not self.check_user_access(request.user): + raise PermissionDenied + + marking = False + + if self.app_name in ("rapids", "anatomy", "longs"): + marking = True + + return render( + request, + "exam_list.html".format(self.app_name), + { + "exams": exams, + "app_name": self.app_name, + "view_all": all, + "marking": marking, + }, + ) + + @method_decorator(login_required) + def exam_overview(self, request, pk): + # print(Exam.objects.all()) + # exams = Exam.objects.all() + # print("test", Exam.objects.all().get(id=pk)) + exam = get_object_or_404(self.Exam.objects.prefetch_related("author"), pk=pk) + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, pk): + raise PermissionDenied + + can_edit = ( + self.check_user_edit_access(request.user, pk) | request.user.is_superuser + ) + + notes = [] + if can_edit: + notes = self.exam_notes(request, pk) + + if self.app_name == "anatomy": + questions = ( + exam.exam_questions.select_related( + "modality", + "structure", + "body_part", + "region", + "examination", + "question_type", + ) + .all() + .prefetch_related( + Prefetch( + "answers", + queryset=self.Answer.objects.filter( + proposed=False, status=self.Answer.MarkOptions.CORRECT + ), + to_attr="prefetched_primary_answer" + # queryset=self.Answer.objects.filter(), + ), + ) + ) + elif self.app_name == "rapids": + questions = ( + exam.exam_questions.select_related() + .all() + .prefetch_related("images", "abnormality", "region", "examination") + ) + elif self.app_name == "physics": + questions = ( + exam.exam_questions.select_related("category").all() + # .prefetch_related("images", "abnormality", "region", "examination") + ) + else: + questions = exam.exam_questions.select_related().all() + + question_number = len(questions) + + return render( + request, + "{}/exam_overview.html".format(self.app_name), + { + "exam": exam, + "questions": questions, + "question_number": question_number, + "can_edit": can_edit, + "notes": notes, + "app_name": self.app_name, + }, + ) + + @method_decorator(login_required) + def exam_scores_refresh(self, request, pk): + exam = get_object_or_404(self.Exam, pk=pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + questions = exam.exam_questions.all() + + cids = self.CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) + + # Force a score update + for c in cids: + c.get_answer_score(cached=False) + + return render( + request, + f"{self.app_name}/base.html", + { + "simple_content": format_html("""Answer scores updated
+ Return""", reverse(f"{self.app_name}:exam_scores_cid", kwargs={"pk": exam.pk})) + }, + ) + + def exam_cids(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(): + raise PermissionDenied + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, exam_id): + raise PermissionDenied + + cid_users = exam.valid_users.all() + + cid_user_count = cid_users.count() + + return render( + request, + "exam_cids.html", + { + "exam": exam, + "cid_users": cid_users, + "cid_user_count": cid_user_count, + "app_name": self.app_name, + }, + ) + + # @method_decorator(login_required) + def exam_notes(self, request, pk): + exam = get_object_or_404(self.Exam, pk=pk) + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, pk): + raise PermissionDenied + + q, content_type = get_question_and_content_type(self.question_type) + + question_pks = exam.exam_questions.values_list("pk", flat=True) + + # Get active notes for type + notes = QuestionNote.objects.filter( + content_type=content_type, object_id__in=question_pks, complete=False + ) + + return notes + + @method_decorator(login_required) + def exam_json_edit(self, request, pk): + if request.is_ajax() and request.method == "POST": + + if not self.check_user_edit_access(request.user, exam_id=pk): + data = {"status": "invalid permisions"} + return JsonResponse(data, status=400) + + exam = get_object_or_404(self.Exam, pk=pk) + + if "add_exam_questions" in request.POST: + question_ids = json.loads(request.POST.get("add_exam_questions")) + question_objects = self.Question.objects.filter(pk__in=question_ids) + + exam.exam_questions.add(question_objects) + exam.save() + data = {"status": "success"} + return JsonResponse(data, status=200) + + if "set_exam_order" in request.POST: + new_order = json.loads(request.POST.get("set_exam_order")) + + preserved = Case( + *[When(pk=pk, then=pos) for pos, pk in enumerate(new_order)] + ) + objects = self.Question.objects.filter(pk__in=new_order).order_by( + preserved + ) + + # We now allow deleting exam questions + # if len(objects) != exam.exam_questions.count(): + # data = {"status": "error, count does not match"} + # return JsonResponse(data, status=400) + + exam.exam_questions.set(objects) + exam.save() + data = {"status": "success"} + return JsonResponse(data, status=200) + + if not self.check_user_access(request.user, pk): + raise PermissionDenied + + if "set_open_access" in request.POST: + if request.POST.get("set_open_access") == "true": + state = True + else: + state = False + + questions_changed = [] + for q in exam.exam_questions.all(): + q.open_access = state + q.save() + questions_changed.append(q.pk) + + data = { + "status": "success", + "questions": questions_changed, + "state": state, + } + return JsonResponse(data, status=200) + + data = {"status": "error, unknown request"} + return JsonResponse(data, status=400) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + @method_decorator(login_required) + def mark_overview(self, request, pk): + exam = get_object_or_404(self.Exam, pk=pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, pk): + raise PermissionDenied + + if self.app_name == "anatomy": + questions = ( + exam.exam_questions.select_related( + "modality", + "structure", + "body_part", + "region", + "examination", + "question_type", + ) + .all() + .prefetch_related( + # "cid_user_answers", + Prefetch( + "answers", + queryset=self.Answer.objects.filter( + proposed=False, status=self.Answer.MarkOptions.CORRECT + ), + to_attr="prefetched_primary_answer" + # queryset=self.Answer.objects.filter(), + ), + # Prefetch( + # "cid_user_answers", + # queryset=self.CidUserAnswer.objects.filter(score=self.Answer.MarkOptions.UNMARKED, exam__id=pk), + # to_attr="prefetched_unmarked_cid_answers" + # #queryset=self.Answer.objects.filter(), + # ), + ) + ) + else: + questions = exam.exam_questions.all() + + # Handle exams that require double marking + try: + if exam.double_mark: + question_unmarked_map = [] + for q in questions: + question_unmarked_map.append( + ( + q, + int(q.get_unmarked_user_answer_count(exam_pk=exam.pk)), + int( + q.get_unmarked_user_answer_count( + exam_pk=exam.pk, marker=request.user + ) + ), + ) + ) + + return render( + request, + "{}/mark_overview.html".format(self.app_name), + {"exam": exam, "question_unmarked_map": question_unmarked_map}, + ) + except AttributeError: + pass + + question_unmarked_map = [] + for q in questions: + count = int(q.get_unmarked_user_answer_count(exam_pk=exam.pk)) + question_unmarked_map.append( + (q, count, count) + ) + + return render( + request, + "{}/mark_overview.html".format(self.app_name), + {"exam": exam, "question_unmarked_map": question_unmarked_map}, + ) + + @method_decorator(login_required) + def index(self, request): + exams = self.Exam.objects.filter(author__id=request.user.id) + + if ( + self.app_name == "rapids" + and request.user.groups.filter(name="rapid_checker").exists() + ): + exams = self.Exam.objects.all() + if ( + self.app_name == "anatomy" + and request.user.groups.filter(name="anatomy_checker").exists() + ): + exams = self.Exam.objects.all() + if ( + self.app_name == "longs" + and request.user.groups.filter(name="long_checker").exists() + ): + exams = self.Exam.objects.all() + + return render(request, "{}/index.html".format(self.app_name), {"exams": exams}) + + @method_decorator(login_required) + def exam_question_detail(self, request, pk, sk): + exam = get_object_or_404(self.Exam, pk=pk) + + if request.user not in exam.author.all(): + if not self.check_user_access(request.user, pk): + raise PermissionDenied + + question = exam.exam_questions.all()[sk] + + exam_length = len(exam.exam_questions.all()) + + pos = exam.get_question_index(question) + 1 + + previous = -1 + if sk > 0: + previous = sk - 1 + next = sk + 1 + if sk == exam_length - 1: + next = False + + return render( + request, + "{}/question_detail.html".format(self.app_name), + { + "question": question, + "exam": exam, + "next": next, + "previous": previous, + "exam_length": exam_length, + "pos": pos, + }, + ) + + def active_exams(self, request, json=True, based=True, cid=None, passcode=None): + exams = self.Exam.objects.filter(archive=False) + + active_exams = {"exams": []} + + if cid is not None: + cid_user = CidUser.objects.filter(cid=cid).first() + if not cid_user or cid_user.passcode != passcode: + if json == False: + return active_exams["exams"] + return JsonResponse({"status": "invalid"}, status=401) + + for exam in exams: + if exam.active or self.check_user_access(request.user, exam.pk): + print(exam.name, cid, passcode) + if exam.exam_mode and not exam.check_cid_user(cid, passcode): + print(exam.name, "fail") + continue + + if exam.json_creation_time: + creation_time = exam.json_creation_time.isoformat() + else: + creation_time = "None" + + url = request.build_absolute_uri(exam.get_json_url()) + # hacky + if not based: + url = url + "/unbased" + + obj = { + "name": exam.get_exam_name(), + "url": url, + "type": self.question_type, + "eid": "{}/{}".format(self.question_type, exam.pk), + "json_creation_time": creation_time, + "exam_json_id": exam.exam_json_id, + "exam_active": exam.active, + "exam_mode": exam.exam_mode, + } + + if self.question_type == "long": + h = {} + for question in exam.exam_questions.all(): + # Generate json if needed + if question.json_creation_time is None: + question.get_question_json() + h[question.pk] = question.question_json_id + obj["multi_question_json"] = h + active_exams["exams"].append(obj) + + if json == False: + return active_exams["exams"] + + return JsonResponse(active_exams) + + @method_decorator(csrf_exempt) + def postExamAnswers(self, request): + if request.is_ajax and request.method == "POST": + + n = 0 + + for answer in json.loads(request.POST.get("answers")): + ret_status, ret = self.loadJsonAnswer(answer) + # try: + # ret_status, ret = self.loadJsonAnswer(answer) + # except: + # # We catch the error here so users get an error (rather than 500) + # return JsonResponse({"success": False, "error": "Invalid data"}) + + # if ret is not True: + # return ret + + if ret_status: + n = n + 1 + else: + return ret + + # print(UserAnswer.objects.filter(exam__id=q["eid"])) + # print(request.urlencode()) + + exam_type, exam_id = request.POST.get("eid").split("/") + + c = CidUser.objects.filter(cid=request.POST.get("cid")).first() + exam = self.Exam.objects.get(pk=exam_id) + t = timezone.make_aware( + datetime.fromtimestamp(float(request.POST.get("start_time"))) + ) + cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c, start_time=t) + + cid_user_exam.end_time = timezone.now() + cid_user_exam.save() + + return JsonResponse({"success": True, "question_count": n}) + return JsonResponse({"success": False, "error": "Invalid data"}) + + def exam_json_unbased(self, request, pk): + """ + No (file based) caching is enabled for unbased exams + """ + exam = get_object_or_404(self.Exam, pk=pk) + + if not exam.active and not self.check_user_access(request.user, pk): + raise Http404("No available exam") + + exam_json = exam.get_exam_json(based=False) + # exam_json["exam_active"] = False + return JsonResponse(exam_json) + + def exam_json(self, request, pk): + + exam = get_object_or_404(self.Exam, pk=pk) + + if not exam.active and not self.check_user_access(request.user, pk): + raise Http404("No available exam") + + # exam_json_cache = cache.get("{}_exam_json_{}".format(self.app_name, pk)) + + path = "{0}{1}/exam/{2}.json".format(settings.MEDIA_ROOT, self.app_name, pk) + url = "{0}{1}/exam/{2}.json".format(settings.MEDIA_URL, self.app_name, pk) + + if os.path.isfile(path) and not exam.recreate_json: + # exam_json_cache["cached"] = True + # Make sure we pass on an argument if we are forcing a reload + if request.GET.get("_"): + query_string = urllib.parse.urlencode({"_": request.GET.get("_")}) + url = "{}?{}".format(url, query_string) + return redirect(url) + return redirect(url) + return JsonResponse(exam_json_cache) + + time = datetime.now() + + exam.exam_json_id += 1 + + with open(path, "w+") as f: + exam_json = exam.get_exam_json() + exam_json["generated"] = time.isoformat() + exam_json["exam_json_id"] = exam.exam_json_id + f.write(json.dumps(exam_json)) + + exam.recreate_json = False + + exam.json_creation_time = time + exam.save() + + # We also try to clear the cache of any associated questions (longs) + # see exam_question_json + # n = exam.exam_questions.count() + # question_keys = ["{}_exam_json_{}_{}".format(self.app_name, pk, i) for i in range(1, n)] + # cache.delete_many(question_keys) + + # cache.set("{}_exam_json_{}".format(self.app_name, pk), exam_json, 3600) + + return JsonResponse(exam_json) + + def exam_question_json(self, request, pk, sk): + question = get_object_or_404(self.Question, pk=sk) + exam = get_object_or_404(self.Exam, pk=pk) + + if not exam.active and not self.check_user_access(request.user, pk): + raise Http404("No available exam") + + if request.GET.get("_"): + base_url = redirect("{}:question_json".format(self.app_name), pk=sk) + query_string = urllib.parse.urlencode({"_": request.GET.get("_")}) + url = "{}?{}".format(base_url, query_string) + return redirect(url) + + return redirect("{}:question_json".format(self.app_name), pk=sk) + + def exam_question_json_unbased(self, request, pk, sk): + question = get_object_or_404(self.Question, pk=sk) + exam = get_object_or_404(self.Exam, pk=pk) + + if not exam.active and not self.check_user_access(request.user, pk): + raise Http404("No available exam") + + return redirect("{}:question_json_unbased".format(self.app_name), pk=sk) + redirect() + + question_json_cache = cache.get("{}_question_json_{}".format(self.app_name, sk)) + + if question_json_cache is not None and not question.recreate_json: + question_json_cache["cached"] = True + return JsonResponse(question_json_cache) + + question_json = exam.get_exam_question_json(sk) + + cache.set("{}_question_json_{}".format(self.app_name, sk), question_json, 3600) + + return JsonResponse(question_json) + + def exam_scores_cid(self, request, pk): + exam = get_object_or_404(self.Exam, pk=pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + user_answers_and_marks = defaultdict(list) + user_answers_marks = defaultdict(list) + user_answers = defaultdict(list) + user_names = {} + # cid_passcodes = {} + + by_question = defaultdict(dict) + unmarked = set() + + questions = exam.exam_questions.all() + + # We could prefect the CidUserAnswers.answers here (if we didn't cache them) + cid_user_answers = self.CidUserAnswer.objects.select_related("question").filter( + question__in=questions, exam__id=pk + ) + + cids = set() + + # Loop through all candidates + for cid_user_answer in cid_user_answers: + # Convoluted (probably...) + cid = cid_user_answer.cid + # cid_passcodes[cid] = cid_user_answer.passcode + cids.add(cid) + s = cid_user_answer + user_names[cid] = cid + + q = cid_user_answer.question + + # if not s: + # # skip if no answer + # user_answers_marks[cid].append(0) + # user_answers[cid].append("") + # by_question[q].append(("", 0)) + # continue + + ans = s.get_answer_string() + + answer_score = s.get_answer_score() + if answer_score == "unmarked": + index = exam.get_question_index(q) + unmarked.add(index) + user_answers[cid].append(ans) + user_answers_marks[cid].append(answer_score) + user_answers_and_marks[cid].append((ans, answer_score)) + + if self.app_name in ("rapids", "anatomy", "sbas"): + by_question[q][cid] = (ans, answer_score) + else: + zipped_ans_scores = zip(ans, answer_score) + by_question[q][cid] = zipped_ans_scores + + user_scores = {} + user_scores_normalised = {} + for user in user_answers_marks: + + if self.app_name in ("rapids", "anatomy", "sbas"): + user_scores[user] = sum( + [i for i in user_answers_marks[user] if i != "unmarked"] + ) + else: + user_scores[user] = sum( + [sum(i) for i in user_answers_marks[user] if i != "unmarked"] + ) + + if self.app_name == "rapids": + user_scores_normalised[user] = normaliseRapidsScore( + sum([i for i in user_answers_marks[user] if i != "unmarked"]) + ) + else: + user_scores_normalised[user] = user_scores[user] + + # ignore scores of 0 for stats + user_scores_list = [i for i in user_scores.values() if i > 0] + + if self.app_name in ("rapids", "anatomy"): + max_score = len(questions) * 2 + elif self.app_name == "physics": + max_score = len(questions) * 5 + else: + max_score = len(questions) + + if len(user_scores_list) < 1: + mean = 0 + median = 0 + mode = 0 + fig_html = "" + else: + mean = statistics.mean(user_scores_list) + median = statistics.median(user_scores_list) + try: + mode = statistics.mode(user_scores_list) + except statistics.StatisticsError: + mode = "No unique mode" + + df = user_scores_list + fig = px.histogram( + df, + x=0, + title="{}: distribution of scores".format(exam), + labels={"0": "Score"}, + height=400, + width=600, + ) + fig_html = fig.to_html() + + exam.stats_mean = mean + exam.stats_median = median + exam.stats_mode = mode + + exam.stats_candidates = len(user_scores_list) + exam.stats_max_possible = max_score + + exam.stats_min = min(user_scores_list) + exam.stats_max = max(user_scores_list) + + exam.stats_graph = fig_html + + exam.user_scores = user_scores + + exam.save() + + return render( + request, + f"{self.app_name}/exam_scores_new.html", + { + "cids": sorted(cids), + # "cid_passcodes": cid_passcodes, + "exam": exam, + "unmarked": unmarked, + "questions": questions, + "by_question": by_question, + "user_answers": dict(user_answers), + "user_answers_marks": dict(user_answers_marks), + "user_scores": user_scores, + "user_scores_normalised": user_scores_normalised, + "user_scores_list": user_scores_list, + "user_names": user_names, + "user_answers_and_marks": user_answers_and_marks, + "max_score": max_score, + "mean": mean, + "median": median, + "mode": mode, + "plot": fig_html, + }, + ) + + +class GenericViewBase: + def __init__(self, app_name, QuestionObject, CidUserAnswerObject, ExamObject): + self.question_object = QuestionObject + self.cid_user_answer_object = CidUserAnswerObject + self.exam_object = ExamObject + self.app_name = app_name + + g = { + "rapids": "rapid_checker", + "anatomy": "anatomy_checker", + "longs": "longs_checker", + "sbas": "sbas_checker", + "physics": "physics_checker", + } + + self.checker_group = g[app_name] + + def question_review(self, request, pk): + """ + Return a json representation of the question when the exam is active + """ + if request.is_ajax(): + exam = get_object_or_404(self.exam_object, pk=pk) + + print(request.POST["question_number"]) + print(type(request.POST["question_number"])) + + if not exam.publish_results: + raise Http404 + + question = exam.exam_questions.all()[int(request.POST["question_number"])] + + question_json = question.get_question_json(based=False) + return JsonResponse(question_json) + return JsonResponse({"status": "error"}) + + @method_decorator(user_passes_test(lambda u: u.is_superuser)) + def user_answer_delete_multiple(self, request): + if request.is_ajax(): + print(request.POST["answer_ids"]) + answer_ids = json.loads(request.POST["answer_ids"]) + + # We could probably delete them all at once.... + for id in answer_ids: + self.cid_user_answer_object.objects.get(pk=id).delete() + return JsonResponse({"status": "success"}) + return JsonResponse({"status": "error"}) + + @method_decorator(login_required) + def question_detail(self, request, pk): + question = get_object_or_404(self.question_object, pk=pk) + + if not question.open_access: + if ( + not request.user.groups.filter(name=self.checker_group).exists() + and request.user not in question.author.all() + ): + raise PermissionDenied() + # if request.user not in rapid.author.all(): + # raise PermissionDenied + + view_feedback = False + if ( + request.user.groups.filter(name=self.checker_group).exists() + or request.user.groups.filter(name="feedback_checker").exists() + or request.user in question.author.all() + ): + view_feedback = True + + # logging.debug(rapid.subspecialty.first().name.all()) + return render( + request, + f"{self.app_name}/question_detail.html", + {"question": question, "view_feedback": view_feedback}, + ) + + @method_decorator(login_required) + def question_user_answers(self, request, pk): + question = get_object_or_404(self.question_object, pk=pk) + + answers = self.cid_user_answer_object.objects.filter(question__id=question.pk) + + return render( + request, + f"{self.app_name}/question_user_answers.html", + {"question": question, "answers": answers}, + ) + + @method_decorator(login_required) + def author_detail(self, request, pk): + # logging.debug(Author.objects.all()) + # author = get_object_or_404(Author, pk=pk) + author = User.objects.get(pk=pk) + + questions = self.question_object.objects.filter(author=pk) + + return render( + request, + f"{self.app_name}/category_detail.html", + {"category": author, "questions": questions}, + ) + + @method_decorator(login_required) + def author_list(self, request): + authors = User.objects.all() + + return render( + request, f"{self.app_name}/author_list.html", {"authors": authors} + ) + + +class ExamCloneMixin: + def get_initial(self): + old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + questions = old_object.exam_questions.all().values_list("id", flat=True) + + self.exam_questions = list(questions) + + return initial_data + + def form_valid(self, form): + object = form.save() + object.exam_questions.set(self.exam_questions) + object.save() + return HttpResponseRedirect(object.get_absolute_url()) + + +class ExaminationAutocomplete(autocomplete.Select2QuerySetView): + def get_queryset(self): + # Don't forget to filter out results depending on the visitor ! + if not self.request.user.is_authenticated: + return Examination.objects.none() + + qs = Examination.objects.all() + + if self.q: + # This raises a fielderror which breaks creating a new item if not caught + try: + qs = qs.filter(examination__icontains=self.q) + except FieldError: + return Examination.objects.none() + + return qs + +class CidUserExamView(CidManagerRequiredMixin, SingleTableMixin, FilterView): + model = CidUser + table_class = CidUserExamTable + template_name = "generic/cid_view.html" + + filterset_class = CidUserFilter + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # user = self.request.user + + filters = {"archive": False, "exam_mode": True} + + physics_exams = [(i.name, i.pk) for i in PhysicsExam.objects.filter(**filters)] + rapid_exams = [(i.name, i.pk) for i in RapidExam.objects.filter(**filters)] + sba_exams = [(i.name, i.pk) for i in SbasExam.objects.filter(**filters)] + longs_exams = [(i.name, i.pk) for i in LongExam.objects.filter(**filters)] + anatomy_exams = [(i.name, i.pk) for i in AnatomyExam.objects.filter(**filters)] + + cid_user_groups = [ + (i.name, i.pk) for i in CidUserGroup.objects.filter(archive=False) + ] + + context["physics_exams"] = physics_exams + context["rapid_exams"] = rapid_exams + context["sba_exams"] = sba_exams + context["longs_exams"] = longs_exams + context["anatomy_exams"] = anatomy_exams + context["cid_user_groups"] = cid_user_groups + context["exams"] = True + return context + +class CidUserView(CidManagerRequiredMixin, SingleTableMixin, FilterView): + model = CidUser + table_class = CidUserTable + template_name = "generic/cid_view.html" + + filterset_class = CidUserFilter + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # user = self.request.user + + filters = {"archive": False, "exam_mode": True} + + physics_exams = [(i.name, i.pk) for i in PhysicsExam.objects.filter(**filters)] + rapid_exams = [(i.name, i.pk) for i in RapidExam.objects.filter(**filters)] + sba_exams = [(i.name, i.pk) for i in SbasExam.objects.filter(**filters)] + longs_exams = [(i.name, i.pk) for i in LongExam.objects.filter(**filters)] + anatomy_exams = [(i.name, i.pk) for i in AnatomyExam.objects.filter(**filters)] + + cid_user_groups = [ + (i.name, i.pk) for i in CidUserGroup.objects.filter(archive=False) + ] + + context["physics_exams"] = physics_exams + context["rapid_exams"] = rapid_exams + context["sba_exams"] = sba_exams + context["longs_exams"] = longs_exams + context["anatomy_exams"] = anatomy_exams + context["cid_user_groups"] = cid_user_groups + + cid_user_groups = [ + (i.name, i.pk) for i in CidUserGroup.objects.filter(archive=False) + ] + + context["cid_user_groups"] = cid_user_groups + return context + + +@login_required +@user_is_cid_user_manager +def group_view(request): + groups = CidUserGroup.objects.filter(archive=False) + + return render( + request, + "generic/group_view.html", + {"groups": groups}, + ) + +@user_is_cid_user_manager +def group_view(request): + groups = CidUserGroup.objects.filter(archive=False) + + return render( + request, + "generic/group_view.html", + {"groups": groups, "view_all": False}, + ) + +@user_is_cid_user_manager +def group_view_all(request): + groups = CidUserGroup.objects.filter() + + return render( + request, + "generic/group_view.html", + {"groups": groups, "view_all": True}, + ) + + +@login_required +@user_is_cid_user_manager +def group_email_resend(request, pk): + return group_email(request, pk, resend=True) + +@login_required +@user_is_cid_user_manager +def group_email_results_resend(request, pk): + return group_email_results(request, pk, resend=True) + + +@login_required +@user_is_cid_user_manager +def group_email(request, pk, resend=False): + group = get_object_or_404(CidUserGroup, pk=pk) + + if resend: + users = group.ciduser_set.filter(active=True) + else: + users = group.ciduser_set.filter(active=True, login_email_sent=False) + + return render( + request, + "generic/group_emailed.html", + #{"sent": sent, "not_sent": not_sent, "users_count": users.count()}, + {"users": users, "users_count": users.count()}, + ) + + #sent = [] + #not_sent = [] + #for u in users: + # success, msg = u.email_details(resend=resend) + # if success: + # sent.append(u) + # else: + # not_sent.append((u, msg)) + + #return render( + # request, + # "generic/group_emailed.html", + # {"sent": sent, "not_sent": not_sent, "users_count": users.count()}, + #) + + +@user_is_cid_user_manager +def group_email_results(request, pk, resend=False): + group = get_object_or_404(CidUserGroup, pk=pk) + + if resend: + users = group.ciduser_set.filter(active=True, internal_candidate=True) + else: + users = group.ciduser_set.filter(active=True, internal_candidate=True, results_email_sent=False) + + #sent = [] + #not_sent = [] + #for u in users: + # success, msg = u.email_results(resend=resend) + # if success: + # sent.append(u) + # else: + # not_sent.append((u, msg)) + + return render( + request, + "generic/group_emailed.html", + #{"sent": sent, "not_sent": not_sent, "users_count": users.count()}, + {"users": users, "users_count": users.count()}, + ) + +@user_is_cid_user_manager +def candidate_email_results(request, cid, resend=False): + user = CidUser.objects.get(cid=cid) + + email_sent, comment = user.email_results(additional_emails=["priya.suresh@nhs.net"], resend=True) + + return JsonResponse({"sent": email_sent, "comment": comment}) + +@user_is_cid_user_manager +def candidate_email_results_resend(request, cid, resend=True): + return candidate_email_results(request, cid, resend=True) + + +@user_is_cid_user_manager +def manage_cid_users(request): + if request.is_ajax() and request.method == "POST": + physics_exams = json.loads(request.POST.get("physics_exams")) + rapid_exams = json.loads(request.POST.get("rapid_exams")) + sba_exams = json.loads(request.POST.get("sba_exams")) + longs_exams = json.loads(request.POST.get("longs_exams")) + anatomy_exams = json.loads(request.POST.get("anatomy_exams")) + cid_groups = json.loads(request.POST.get("cid_groups")) + + selected_cids = json.loads(request.POST.get("selected_cids")) + + emails = json.loads(request.POST.get("emails")) + + number_to_create = int(request.POST.get("number_to_create")) + + add_group = request.POST.get("add_group") + delete = request.POST.get("delete") + activate = request.POST.get("activate") + deactivate = request.POST.get("deactivate") + add_exams = request.POST.get("add_exams") + clear_exams = request.POST.get("clear_exams") + toggle_internal = request.POST.get("toggle_internal") + + if add_group == "true": + cids = CidUser.objects.filter(pk__in=selected_cids) + for c in cids: + c.group_id = cid_groups[0] + c.save() + return JsonResponse({"status": "success"}) + + if delete == "true": + cids = CidUser.objects.filter(pk__in=selected_cids).delete() + return JsonResponse({"status": "success"}) + + if activate == "true": + cids = CidUser.objects.filter(pk__in=selected_cids) + for c in cids: + c.active = True + c.save() + return JsonResponse({"status": "success"}) + + if toggle_internal == "true": + cids = CidUser.objects.filter(pk__in=selected_cids) + for c in cids: + c.internal_candidate = not c.internal_candidate + c.save() + return JsonResponse({"status": "success"}) + + if deactivate == "true": + cids = CidUser.objects.filter(pk__in=selected_cids) + for c in cids: + c.active = False + c.save() + return JsonResponse({"status": "success"}) + + if clear_exams == "true": + cids = CidUser.objects.filter(pk__in=selected_cids) + for c in cids: + c.physics_exams.clear() + c.rapid_exams.clear() + c.sba_exams.clear() + c.longs_exams.clear() + c.anatomy_exams.clear() + return JsonResponse({"status": "success"}) + + if add_exams == "true": + cids = CidUser.objects.filter(pk__in=selected_cids) + for c in cids: + if physics_exams: + c.physics_exams.set(physics_exams) + c.save() + if rapid_exams: + c.rapid_exams.set(rapid_exams) + c.save() + if sba_exams: + c.sba_exams.set(sba_exams) + c.save() + if longs_exams: + c.longs_exams.set(longs_exams) + c.save() + if anatomy_exams: + c.anatomy_exams.set(anatomy_exams) + c.save() + return JsonResponse({"status": "success"}) + + try: + new_cid = CidUser.objects.all().order_by("-cid")[0].cid + 1 + except IndexError: + new_cid = 10000 + + if emails: + email_list = [i.strip() for i in emails.split()] + for e in email_list: + passcode = "".join(random.choices(string.ascii_uppercase, k=4)) + c = CidUser(cid=new_cid, passcode=passcode, email=e) + c.save() + if cid_groups: + c.group_id = cid_groups[0] + + if physics_exams: + c.physics_exams.set(physics_exams) + c.save() + if rapid_exams: + c.rapid_exams.set(rapid_exams) + c.save() + if sba_exams: + c.sba_exams.set(sba_exams) + c.save() + if longs_exams: + c.longs_exams.set(longs_exams) + c.save() + if anatomy_exams: + c.anatomy_exams.set(anatomy_exams) + c.save() + new_cid = new_cid + 1 + + for n in range(number_to_create): + passcode = "".join(random.choices(string.ascii_uppercase, k=4)) + c = CidUser(cid=new_cid, passcode=passcode) + c.save() + if cid_groups: + c.group_id = cid_groups[0] + + if physics_exams: + c.physics_exams.set(physics_exams) + c.save() + if rapid_exams: + c.rapid_exams.set(rapid_exams) + c.save() + if sba_exams: + c.sba_exams.set(sba_exams) + c.save() + if longs_exams: + c.longs_exams.set(longs_exams) + c.save() + if anatomy_exams: + c.anatomy_exams.set(anatomy_exams) + c.save() + new_cid = new_cid + 1 + + return JsonResponse({"status": "success"}) + return JsonResponse({"status": "fail"}) + + +class CidUserCreate(RevisionMixin, CidManagerRequiredMixin, CreateView): + model = CidUser + form_class = CidUserForm + +class CidUserUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView): + model = CidUser + form_class = CidUserForm + +class CidUserGroupCreate(RevisionMixin, CidManagerRequiredMixin, CreateView): + model = CidUserGroup + form_class = CidUserGroupForm + +class CidUserGroupUpdate(RevisionMixin, CidManagerRequiredMixin, UpdateView): + model = CidUserGroup + form_class = CidUserGroupForm diff --git a/longs/forms.py b/longs/forms.py index 9120ef4a..ba0577d5 100755 --- a/longs/forms.py +++ b/longs/forms.py @@ -1,280 +1,280 @@ -from django.forms import ( - Form, - ModelForm, - ModelMultipleChoiceField, - ModelChoiceField, - ChoiceField, - CharField, -) -from django.forms import inlineformset_factory - -from longs.models import ( - # Examination, - AnswerMarks, - Long, - LongSeries, - LongSeriesImage, - LongCreationDefault, - CidUserAnswer, - Exam, -) - -from anatomy.models import Modality - -from generic.models import Examination, Condition, Sign - -# from generic.models import Examination, Site, Condition, Sign - -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.forms.widgets import RadioSelect, TextInput, Textarea - -from tinymce.widgets import TinyMCE - - -class LongAnswerForm(ModelForm): - class Meta: - model = CidUserAnswer - fields = ( - "answer_observations", - "answer_interpretation", - "answer_principle_diagnosis", - "answer_differential_diagnosis", - "answer_management", - ) - - -class MarkLongQuestionSingleForm(ModelForm): - class Meta: - model = CidUserAnswer - fields = ["score"] - - -class MarkLongQuestionDoubleForm(ModelForm): - class Meta: - model = AnswerMarks - fields = ["score", "mark_reason", "candidate_feedback"] - - -class ExaminationForm(ModelForm): - class Meta: - model = Examination - fields = ["examination"] - - -class LongCreationDefaultForm(ModelForm): - class Meta: - model = LongCreationDefault - # fields = ["site"] - exclude = ["author"] - - -class LongSeriesForm(ModelForm): - class Media: - # Django also includes a few javascript files necessary - # for the operation of this form element. You need to - # include - # in the template. - css = { - "all": ["css/widgets.css"], - } - # Adding this javascript is crucial - js = ["jsi18n.js", "tesseract.min.js"] - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop( - "user" - ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole - if kwargs.get("instance"): - # We get the 'initial' keyword argument or initialize it - # as a dict if it didn't exist. - initial = kwargs.setdefault("initial", {}) - # The widget for a ModelMultipleChoiceField expects - # a list of primary key for the selected data. - initial["long"] = [t.pk for t in kwargs["instance"].long.all()] - - super(LongSeriesForm, self).__init__(*args, **kwargs) - # self.fields["examination"] = ModelChoiceField( - # queryset=Examination.objects.all(), - # widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False), - # ) - - self.fields["modality"] = ModelChoiceField( - required=True, - queryset=Modality.objects.all(), - ) - - ModelForm.__init__(self, *args, **kwargs) - - if self.user.groups.filter(name="long_checker").exists(): - long_queryset = Long.objects.all() - else: - long_queryset = Long.objects.filter( - author__id=self.user.id - ) | Long.objects.filter(open_access=True) - - self.fields["long"] = ModelMultipleChoiceField( - required=False, - queryset=long_queryset, - widget=FilteredSelectMultiple(verbose_name="Long", is_stacked=False), - ) - - def save(self, commit=True): - # Get the unsaved Pizza instance - instance = ModelForm.save(self, False) - - # Prepare a 'save_m2m' method for the form, - old_save_m2m = self.save_m2m - - def save_m2m(): - old_save_m2m() - # This is where we actually link the pizza with toppings - instance.long.clear() - for long in self.cleaned_data["long"]: - instance.long.add(long) - - self.save_m2m = save_m2m - - # Do we need to save all changes now? - # if commit: - instance.save() - self.save_m2m() - - return instance - - class Meta: - model = LongSeries - exclude = ["author"] - - -class LongForm(ModelForm): - - # exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False)) - - class Media: - # Django also includes a few javascript files necessary - # for the operation of this form element. You need to - # include - # in the template. - css = { - "all": ["css/widgets.css"], - } - # Adding this javascript is crucial - js = ["jsi18n.js", "tesseract.min.js"] - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop( - "user" - ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole - if kwargs.get("instance"): - # We get the 'initial' keyword argument or initialize it - # as a dict if it didn't exist. - initial = kwargs.setdefault("initial", {}) - # The widget for a ModelMultipleChoiceField expects - # a list of primary key for the selected data. - initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] - - ModelForm.__init__(self, *args, **kwargs) - - super(LongForm, self).__init__(*args, **kwargs) - - if self.user.groups.filter(name="long_checker").exists(): - exam_queryset = Exam.objects.all() - else: - exam_queryset = Exam.objects.filter( - author__id=self.user.id - ) | Exam.objects.filter(open_access=True) - - self.fields["exams"] = ModelMultipleChoiceField( - required=False, - queryset=exam_queryset, - widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), - ) - - class Meta: - model = Long - # fields = ['due_back'] - # fields = '__all__' - fields = [ - # "examination", - # "site", - "description", - "history", - "feedback", - "condition", - "sign", - "model_observations", - "model_interpretation", - "model_principle_diagnosis", - "model_differential_diagnosis", - "model_management", - "mark_scheme", - "open_access", - ] - # fields = ['question', 'feedback', 'subspecialty', 'references'] - widgets = { - # "normal": RadioSelect( - # choices=[(True, 'Yes'), - # (False, 'No')]) - "feedback": TinyMCE(attrs={"cols": 80, "rows": 20}), - "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}), - "description": Textarea(attrs={"cols": 80, "rows": 5}), - "history": Textarea(attrs={"cols": 80, "rows": 5}), - "model_observations": Textarea(attrs={"cols": 80, "rows": 5}), - "model_interpretation": Textarea(attrs={"cols": 80, "rows": 5}), - "model_principle_diagnosis": Textarea(attrs={"cols": 80, "rows": 5}), - "model_differential_diagnosis": Textarea(attrs={"cols": 80, "rows": 5}), - "model_management": Textarea(attrs={"cols": 80, "rows": 5}), - } - - def save(self, commit=True): - instance = ModelForm.save(self, False) - instance.save() - - old_exams = instance.exams.all() - - new_exams = self.cleaned_data["exams"] - - for exam in old_exams: - if exam not in new_exams: - exam.exam_questions.remove(instance) - - for exam in new_exams: - exam.exam_questions.add(instance) - self.save_m2m() - - return instance - - -SeriesFormSet = inlineformset_factory( - Long, - LongSeries.long.through, - exclude=[], - can_delete=True, - extra=0, - max_num=10, -) - -LongSeriesImageFormSet = inlineformset_factory( - LongSeries, - LongSeriesImage, - exclude=[], - can_delete=True, - extra=0, - max_num=2000, - field_classes="testing", -) - - -class ExamForm(ModelForm): - class Meta: - model = Exam - fields = [ - "name", - "time_limit", - "open_access", - "exam_mode", - "active", - "publish_results", - "double_mark", - "archive", - ] +from django.forms import ( + Form, + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, + CharField, +) +from django.forms import inlineformset_factory + +from longs.models import ( + # Examination, + AnswerMarks, + Long, + LongSeries, + LongSeriesImage, + LongCreationDefault, + CidUserAnswer, + Exam, +) + +from anatomy.models import Modality + +from generic.models import Examination, Condition, Sign + +# from generic.models import Examination, Site, Condition, Sign + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms.widgets import RadioSelect, TextInput, Textarea + +from tinymce.widgets import TinyMCE + + +class LongAnswerForm(ModelForm): + class Meta: + model = CidUserAnswer + fields = ( + "answer_observations", + "answer_interpretation", + "answer_principle_diagnosis", + "answer_differential_diagnosis", + "answer_management", + ) + + +class MarkLongQuestionSingleForm(ModelForm): + class Meta: + model = CidUserAnswer + fields = ["score"] + + +class MarkLongQuestionDoubleForm(ModelForm): + class Meta: + model = AnswerMarks + fields = ["score", "mark_reason", "candidate_feedback"] + + +class ExaminationForm(ModelForm): + class Meta: + model = Examination + fields = ["examination"] + + +class LongCreationDefaultForm(ModelForm): + class Meta: + model = LongCreationDefault + # fields = ["site"] + exclude = ["author"] + + +class LongSeriesForm(ModelForm): + class Media: + # Django also includes a few javascript files necessary + # for the operation of this form element. You need to + # include + # in the template. + css = { + "all": ["css/widgets.css"], + } + # Adding this javascript is crucial + js = ["jsi18n.js", "tesseract.min.js"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop( + "user" + ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + initial["long"] = [t.pk for t in kwargs["instance"].long.all()] + + super(LongSeriesForm, self).__init__(*args, **kwargs) + # self.fields["examination"] = ModelChoiceField( + # queryset=Examination.objects.all(), + # widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False), + # ) + + self.fields["modality"] = ModelChoiceField( + required=True, + queryset=Modality.objects.all(), + ) + + ModelForm.__init__(self, *args, **kwargs) + + if self.user.groups.filter(name="long_checker").exists(): + long_queryset = Long.objects.all() + else: + long_queryset = Long.objects.filter( + author__id=self.user.id + ) | Long.objects.filter(open_access=True) + + self.fields["long"] = ModelMultipleChoiceField( + required=False, + queryset=long_queryset, + widget=FilteredSelectMultiple(verbose_name="Long", is_stacked=False), + ) + + def save(self, commit=True): + # Get the unsaved Pizza instance + instance = ModelForm.save(self, False) + + # Prepare a 'save_m2m' method for the form, + old_save_m2m = self.save_m2m + + def save_m2m(): + old_save_m2m() + # This is where we actually link the pizza with toppings + instance.long.clear() + for long in self.cleaned_data["long"]: + instance.long.add(long) + + self.save_m2m = save_m2m + + # Do we need to save all changes now? + # if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = LongSeries + exclude = ["author"] + + +class LongForm(ModelForm): + + # exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False)) + + class Media: + # Django also includes a few javascript files necessary + # for the operation of this form element. You need to + # include + # in the template. + css = { + "all": ["css/widgets.css"], + } + # Adding this javascript is crucial + js = ["jsi18n.js", "tesseract.min.js"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop( + "user" + ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] + + ModelForm.__init__(self, *args, **kwargs) + + super(LongForm, self).__init__(*args, **kwargs) + + if self.user.groups.filter(name="long_checker").exists(): + exam_queryset = Exam.objects.all() + else: + exam_queryset = Exam.objects.filter( + author__id=self.user.id + ) | Exam.objects.filter(open_access=True) + + self.fields["exams"] = ModelMultipleChoiceField( + required=False, + queryset=exam_queryset, + widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), + ) + + class Meta: + model = Long + # fields = ['due_back'] + # fields = '__all__' + fields = [ + # "examination", + # "site", + "description", + "history", + "feedback", + "condition", + "sign", + "model_observations", + "model_interpretation", + "model_principle_diagnosis", + "model_differential_diagnosis", + "model_management", + "mark_scheme", + "open_access", + ] + # fields = ['question', 'feedback', 'subspecialty', 'references'] + widgets = { + # "normal": RadioSelect( + # choices=[(True, 'Yes'), + # (False, 'No')]) + "feedback": TinyMCE(attrs={"cols": 80, "rows": 20}), + "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}), + "description": Textarea(attrs={"cols": 80, "rows": 5}), + "history": Textarea(attrs={"cols": 80, "rows": 5}), + "model_observations": Textarea(attrs={"cols": 80, "rows": 5}), + "model_interpretation": Textarea(attrs={"cols": 80, "rows": 5}), + "model_principle_diagnosis": Textarea(attrs={"cols": 80, "rows": 5}), + "model_differential_diagnosis": Textarea(attrs={"cols": 80, "rows": 5}), + "model_management": Textarea(attrs={"cols": 80, "rows": 5}), + } + + def save(self, commit=True): + instance = ModelForm.save(self, False) + instance.save() + + old_exams = instance.exams.all() + + new_exams = self.cleaned_data["exams"] + + for exam in old_exams: + if exam not in new_exams: + exam.exam_questions.remove(instance) + + for exam in new_exams: + exam.exam_questions.add(instance) + self.save_m2m() + + return instance + + +SeriesFormSet = inlineformset_factory( + Long, + LongSeries.long.through, + exclude=[], + can_delete=True, + extra=0, + max_num=10, +) + +LongSeriesImageFormSet = inlineformset_factory( + LongSeries, + LongSeriesImage, + exclude=[], + can_delete=True, + extra=0, + max_num=2000, + field_classes="testing", +) + + +class ExamForm(ModelForm): + class Meta: + model = Exam + fields = [ + "name", + "time_limit", + "open_access", + "exam_mode", + "active", + "publish_results", + "double_mark", + "archive", + ] diff --git a/longs/templates/longs/base.html b/longs/templates/longs/base.html index f44f8550..aa0db0fa 100755 --- a/longs/templates/longs/base.html +++ b/longs/templates/longs/base.html @@ -1,33 +1,33 @@ -{% extends 'base.html' %} - -{% block title %} -Longs -{% endblock %} - -{% block css %} -{% endblock %} - -{% block js %} -{% endblock %} - - - -{% block content %} -{% endblock %} -{% block navigation %} -Longs: -{% if request.user.is_authenticated %} -Exams / -Create Exam / -Cases / -Series / -Create Case / -Create Series -{% endif %} -{% if request.user.is_superuser %} - / Answers -{% endif %} -{% comment %}
-Questions by: -author {% endcomment %} +{% extends 'base.html' %} + +{% block title %} +Longs +{% endblock %} + +{% block css %} +{% endblock %} + +{% block js %} +{% endblock %} + + + +{% block content %} +{% endblock %} +{% block navigation %} +Longs: +{% if request.user.is_authenticated %} +Exams / +Create Exam / +Cases / +Series / +Create Case / +Create Series +{% endif %} +{% if request.user.is_superuser %} + / Answers +{% endif %} +{% comment %}
+Questions by: +author {% endcomment %} {% endblock %} \ No newline at end of file diff --git a/longs/views.py b/longs/views.py index 6cee80c5..3d244998 100755 --- a/longs/views.py +++ b/longs/views.py @@ -1,1339 +1,1339 @@ -import json -from django.shortcuts import render, get_object_or_404, redirect -from django import forms - -# from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required, user_passes_test -from django.contrib.auth.models import User -from django.core.exceptions import ( - ObjectDoesNotExist, - PermissionDenied, - ViewDoesNotExist, -) - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic.detail import DetailView -from generic.mixins import SuperuserRequiredMixin - -from django.views.generic.edit import CreateView, UpdateView, DeleteView -from django.views.generic import ListView -from django.views.decorators.csrf import csrf_exempt - -from django.urls import reverse_lazy, reverse - -from django.http import Http404, JsonResponse -from django.http import HttpResponseRedirect, HttpResponse - -from .forms import ( - LongForm, - LongSeriesForm, - LongSeriesImageFormSet, - MarkLongQuestionDoubleForm, - MarkLongQuestionSingleForm, - SeriesFormSet, - ExaminationForm, - ExamForm, -) -from .models import ( - AnswerMarks, - Long, - LongSeries, - Examination, - Exam, - CidUserAnswer, -) -from .tables import LongTable, LongSeriesTable, UserAnswerTable -from .filters import LongFilter, LongSeriesFilter, UserAnswerFilter - -from django_tables2 import SingleTableView, SingleTableMixin -from django_filters.views import FilterView - -from .decorators import ( - user_is_author_or_long_checker, - user_is_author_or_long_checker_or_long_marker, - user_is_author_or_long_series_checker_or_long_marker, - user_is_long_checker, - user_is_author_or_long_series_checker, - user_is_long_marker, - user_is_author_or_long_checker_or_long_marker, -) - -from collections import defaultdict -import json -import statistics -import plotly.express as px - -from django.core.cache import cache -from django.conf import settings - -from helpers.images import image_as_base64 - -import logging -from copy import deepcopy -from django.forms.models import model_to_dict -from longs.forms import LongCreationDefaultForm -from longs.models import LongCreationDefault - -from generic.views import ExamCloneMixin, ExamViews -from reversion.views import RevisionMixin -import reversion - - -from rest_framework import viewsets - -from .serializers import ExamSerializer - -from zipview.views import BaseZipView - -import os - -logger = logging.getLogger(__name__) - - -def normaliseScore(score): - if score >= 25.5 and score <= 28: - return 4.5 - elif score >= 28.5 and score <= 31: - return 5 - elif score >= 31.5 and score <= 34: - return 5.5 - elif score >= 34.5 and score <= 37: - return 6 - elif score >= 37.5 and score <= 40: - return 6.5 - elif score >= 40.5 and score <= 43: - return 7 - elif score >= 43.5 and score <= 46: - return 7.5 - elif score >= 46.5 and score <= 48: - return 7.5 - - return 4 - - -class AuthorOrCheckerRequiredMixin(object): - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if ( - self.request.user.groups.filter(name="long_checker").exists() - or self.request.user.is_superuser - ): - return obj - if self.request.user not in obj.get_author_objects(): - raise PermissionDenied() # or Http404 - return obj - - -# def index(request): -# other_longs = Long.objects.exclude(author=request.user.pk) -# user_longs = Long.objects.filter(author=request.user.pk) -# return render( -# request, -# "longs/index.html", -# {"other_longs": other_longs, "user_longs": user_longs}, -# ) - - -@login_required -@user_is_author_or_long_checker_or_long_marker -def question_detail(request, pk): - long = get_object_or_404(Long, pk=pk) - - # if request.user not in long.author.all(): - # raise PermissionDenied - - # logging.debug(long.subspecialty.first().name.all()) - return render(request, "longs/question_detail.html", {"question": long}) - - -@login_required -@user_is_author_or_long_series_checker_or_long_marker -def long_series_detail(request, pk): - series = get_object_or_404(LongSeries, pk=pk) - - # if request.user not in long.author.all(): - # raise PermissionDenied - - # logging.debug(long.subspecialty.first().name.all()) - return render(request, "longs/long_series.html", {"series": series}) - - -@login_required -@user_is_author_or_long_checker -def long_split(request, pk): - long = get_object_or_404(Long, pk=pk) - - images = long.images.all() - - old_abnormality = long.abnormality.all() - old_region = long.region.all() - old_examination = long.examination.all() - old_site = long.site.all() - old_author = long.author.all() - - if not images: - raise Http404 - - for n in range(len(images) - 1): - long.pk = None - - long.save() - - long.abnormality.set(old_abnormality) - long.region.set(old_region) - long.examination.set(old_examination) - long.site.set(old_site) - long.author.set(old_author) - images[n].long = long - images[n].save() - - # images[-1].long - - # if request.user not in long.author.all(): - # raise PermissionDenied - - # logging.debug(long.subspecialty.first().name.all()) - return render(request, "longs/question_detail.html", {"question": long}) - - -@login_required -@user_is_author_or_long_checker -def author_detail(request, pk): - # logging.debug(Author.objects.all()) - # author = get_object_or_404(Author, pk=pk) - author = User.objects.get(pk=pk) - - longs = Long.objects.filter(author=pk) - - return render( - request, "longs/category_detail.html", {"category": author, "longs": longs} - ) - - -@user_is_author_or_long_checker -def author_list(request): - authors = User.objects.all() - - return render(request, "longs/author_list.html", {"authors": authors}) - - -class LongCreationDefaultView(LoginRequiredMixin, UpdateView): - model = LongCreationDefault - form_class = LongCreationDefaultForm - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - # def dispatch(self, request, *args, **kwargs): - # """ - # Overridden so we can make sure the `Long` instance exists - # before going any further. - # """ - # self.pk = get_object_or_404(Long, pk=kwargs['pk']) - # return super().dispatch(request, *args, **kwargs) - def get_object(self, queryset=None): - obj, create = LongCreationDefault.objects.get_or_create( - author=self.request.user - ) - - return obj - - def form_valid(self, form): - - model = form.save(commit=False) - - model.author = self.request.user - model.save() - - response = super().form_valid(form) - - return response - - # form.instance.author.add(self.request.user.id) - - -class LongDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Long - success_url = reverse_lazy("longs:long_view") - - -class LongSeriesDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = LongSeries - success_url = reverse_lazy("longs:long_series_view") - - -@login_required -@user_is_author_or_long_checker -def long_clone(request, pk): - new_item = get_object_or_404(Long, pk=pk) - new_item.pk = None # autogen a new pk (item_id) - # new_item.name = "Copy of " + new_item.name #need to change uniques - - form = LongForm(request.POST or None, instance=new_item) - - series_formset = SeriesFormSet() - - if form.is_valid(): - form.instance.author.add(request.user.id) - - # logger.debug(formset.is_valid()) - if series_formset.is_valid(): - response = super().form_valid(form) - series_formset.instance = obj - series_formset.save() - - return response - else: - return super().form_invalid(form) - - context = { - "form": form, - "series_formset": series_formset, - # other context - } - - return render(request, "longs/long_form.html", context) - - -class LongSeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = LongSeries - form_class = LongSeriesForm - - def get_initial(self): - # print(self.request) - if "pk" in self.kwargs: - initial = super().get_initial() - long = get_object_or_404(Long, pk=self.kwargs["pk"]) - - initial["long"] = long.id - - return initial - - def get_form_kwargs(self): - kwargs = super(LongSeriesCreate, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(LongSeriesCreate, self).get_context_data(**kwargs) - - if self.request.POST: - context["image_formset"] = LongSeriesImageFormSet( - self.request.POST, self.request.FILES - ) - context["image_formset"].full_clean() - else: - context["image_formset"] = LongSeriesImageFormSet() - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - series_formset = context["image_formset"] - if series_formset.is_valid(): - response = super().form_valid(form) - series_formset.instance = self.object - series_formset.save() - return response - - else: - return super().form_invalid(form) - - -class LongSeriesUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = LongSeries - form_class = LongSeriesForm - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - - def get_form_kwargs(self): - kwargs = super(LongSeriesUpdate, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(LongSeriesUpdate, self).get_context_data(**kwargs) - if self.request.POST: - context["image_formset"] = LongSeriesImageFormSet( - self.request.POST, self.request.FILES, instance=self.object - ) - context["image_formset"].full_clean() - else: - context["image_formset"] = LongSeriesImageFormSet(instance=self.object) - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - self.object.save() - - # Do this in the form.save() - # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) - # self.object.exams.clear() - # self.object.exams.add(*new_exams) - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - image_formset = context["image_formset"] - # logger.debug(formset.is_valid()) - if image_formset.is_valid(): - response = super().form_valid(form) - image_formset.instance = self.object - image_formset.save() - return response - else: - return super().form_invalid(form) - - -class LongCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): - model = Long - form_class = LongForm - - def get_form_kwargs(self): - kwargs = super(LongCreateBase, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(LongCreateBase, self).get_context_data(**kwargs) - if self.request.POST: - context["series_formset"] = SeriesFormSet( - self.request.POST, self.request.FILES - ) - context["series_formset"].full_clean() - else: - context["series_formset"] = SeriesFormSet() - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - - # Add exam objects - - self.object.save() - - # Do this in the form.save() - # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) - # self.object.exams.clear() - # self.object.exams.add(*new_exams) - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - series_formset = context["series_formset"] - if series_formset.is_valid(): - response = super().form_valid(form) - series_formset.instance = self.object - series_formset.save() - # If the normal submit button is pressed we save as normal - if "submit" in self.request.POST: - return response - # else we redirect to the clone url - else: - return redirect("longs:long_clone", pk=self.object.pk) - - else: - return super().form_invalid(form) - - -# @login_required -class LongCreate(LongCreateBase): - def get_initial(self): - # There has to be a better way... - try: - s = (i.pk for i in self.request.user.long_default.site.all()) - self.initial.update({"site": s}) - except AttributeError: - pass - return self.initial - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - - # self.object = form.save(commit=False) - # self.object.save() - - # form.instance.author.add(self.request.user.id) - # return super().form_valid(form) - - -class LongUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Long - form_class = LongForm - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - - def get_form_kwargs(self): - kwargs = super(LongUpdate, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(LongUpdate, self).get_context_data(**kwargs) - if self.request.POST: - context["series_formset"] = SeriesFormSet( - self.request.POST, self.request.FILES, instance=self.object - ) - context["series_formset"].full_clean() - else: - context["series_formset"] = SeriesFormSet(instance=self.object) - return context - - def form_valid(self, form): - # save exam orders (there must be a better way to do this) - exam_orders = {} - for exam in self.object.exams.all(): - exam_orders[exam] = list(exam.exam_questions.all()) - - self.object = form.save(commit=False) - self.object.save() - - # Do this in the form.save() - # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) - # self.object.exams.clear() - # self.object.exams.add(*new_exams) - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - series_formset = context["series_formset"] - # logger.debug(formset.is_valid()) - if series_formset.is_valid(): - response = super().form_valid(form) - series_formset.instance = self.object - series_formset.save() - - # restore exam orders - for exam in self.object.exams.all(): - if exam in exam_orders and self.object in exam_orders[exam]: - print(exam_orders[exam]) - exam.exam_questions.set(exam_orders[exam]) - exam.save() - - return response - else: - return super().form_invalid(form) - - -class LongClone(LongCreateBase): - """Clones a existing long""" - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - def get_initial(self): - # print(self.request) - old_object = get_object_or_404(Long, pk=self.kwargs["pk"]) - initial_data = model_to_dict(old_object, exclude=["id"]) - - return initial_data - - -@login_required -@user_is_author_or_long_checker -def long_scrap(request, pk): - try: - long = Long.objects.get(pk=pk) - except Long.DoesNotExist: - raise Http404("Long does not exist") - - long.scrapped = not long.scrapped - long.save() - return HttpResponseRedirect(reverse("longs:question_detail", args=(pk,))) - - -# @login_required -# def edit_abnormality_popup(request): -# instance = get_object_or_404(Abnormality, pk = pk) -# form = AbnormalityForm(request.POST or None) -# if form.is_valid(): -# instance = form.save() -# return HttpResponse('' % (instance.pk, instance)) -# return render(request, "longs/create_simple.html", {'form': form}) - - -@login_required -def create_examination(request): - form = ExaminationForm(request.POST or None) - if form.is_valid(): - instance = form.save() - return HttpResponse( - '' - % (instance.pk, instance) - ) - return render( - request, "longs/create_simple.html", {"form": form, "name": "Examination"} - ) - - -@csrf_exempt -def get_examination_id(request): - if request.is_ajax(): - examination_name = request.GET["examination_name"] - examination_id = Examination.objects.get(name=examination_name).id - data = { - "examination_id": examination_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -class LongView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Long - table_class = LongTable - template_name = "longs/view.html" - - filterset_class = LongFilter - - -class LongSeriesView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = LongSeries - table_class = LongSeriesTable - template_name = "longs/view.html" - - filterset_class = LongSeriesFilter - - -def loadJsonAnswer(answer): - eid = int(answer["eid"].split("/")[1]) - cid = int(answer["cid"]) - - # As access is not restricted make sure the data appears valid - if (not isinstance(cid, int)) or ( - not isinstance(eid, int) or (not isinstance(answer["ans"], str)) - ): - return False, JsonResponse( - {"success": False, "error": "cid or eid or answers not defined"} - ) - - # The model should catch invalid data but this should be less intensive - max_int = 999999999999999999999 - if cid > max_int or eid > max_int: - return False, JsonResponse({"success": False, "error": "invalid cid"}) - - exam = get_object_or_404(Exam, pk=eid) - - if not exam.check_cid_user(cid, answer["passcode"]): - return False, JsonResponse( - {"success": False, "error": "invalid cid / passcode"} - ) - - # if not exam.active: - # return False, JsonResponse( - # {"success": False, "error": "No active exam: {}".format(eid)} - # ) - - exiting_answers = CidUserAnswer.objects.filter( - question__id=answer["qid"], exam__id=eid, cid=cid - ) - - posted_answer = answer["ans"] - - # Long cases seperate sections by qidn - qidn = answer["qidn"] - - # If the user answer does not exist - if not exiting_answers: - ans = CidUserAnswer(cid=cid) - ans.question_id = answer["qid"] - ans.exam_id = eid - - # If the answer already exists or we have started populating it - else: - # Update an existing answer - # should never be more than one (famous last words) - ans = exiting_answers[0] - - if qidn == "1": - ans.answer_observations = posted_answer - elif qidn == "2": - ans.answer_interpretation = posted_answer - elif qidn == "3": - ans.answer_principle_diagnosis = posted_answer - elif qidn == "4": - ans.answer_differential_diagnosis = posted_answer - elif qidn == "5": - ans.answer_management = posted_answer - - # Mark as unmarked - ans.score = ans.ScoreOptions.UNMARKED - - ans.full_clean() - ans.save() - - return True, None - - -@user_is_long_marker -@reversion.create_revision() -def mark_answer_override(request, exam_id, question_number, cid): - return mark_answer(request, exam_id, question_number, cid, override=True) - - -@user_is_long_marker -@reversion.create_revision() -def mark_answer(request, exam_id, question_number, cid, override=False): - exam = get_object_or_404(Exam, pk=exam_id) - - questions = exam.exam_questions.all() - - n = question_number - - question_details = { - "total": len(questions), - "current": n + 1, - } - - try: - question = questions[question_number] - except IndexError: - raise Http404("Exam question does not exist") - - try: - answer = question.cid_user_answers.get(cid=cid, exam__id=exam_id) - except ObjectDoesNotExist: - raise Http404("User answer does not exist") - - cid_list = list( - question.cid_user_answers.filter(exam__id=exam_id).values_list("cid", flat=True) - ) - - previous_cid = False - next_cid = False - - if len(cid_list) > 1: - if cid_list[0] == cid: - next_cid = cid_list[1] - elif cid_list[-1] == cid: - previous_cid = cid_list[-2] - else: - next_cid = cid_list[cid_list.index(cid) + 1] - previous_cid = cid_list[cid_list.index(cid) - 1] - - try: - if exam.double_mark: - unmarked = question.get_unmarked_user_answers( - exam_pk=exam.id, marker=request.user - ) - else: - unmarked = question.get_unmarked_user_answers(exam_pk=exam.id) - next_unmarked_id = unmarked[0].cid - if next_unmarked_id == cid: - next_unmarked_id = unmarked[1].cid - except IndexError: - next_unmarked_id = False - - # We use different forms if the exam should be single or double marked - - if not exam.double_mark or ( - request.method == "POST" and request.POST["form_id"] == "discrepancy_form" - ): - if request.method == "POST": - form = MarkLongQuestionSingleForm(request.POST) - - if form.is_valid(): - # If skip button is pressed skip the question without marking - # Skip is problematic if we skip to the next unmarked answer - # if "skip" in request.POST: - # return redirect("longs:mark_answer", pk=exam_id, sk=question_number, cid=next_unmarked_id) - - # Extract score from form and save it to the object - answer.score = form.cleaned_data["score"] - answer.save() - - if "next" in request.POST: - return redirect( - "longs:mark_answer", - exam_id=exam_id, - question_number=question_number, - cid=next_unmarked_id, - ) - if "save" in request.POST: - return redirect( - "longs:mark_answer", - exam_id=exam_id, - question_number=question_number, - cid=cid, - ) - # elif "previous" in request.POST: - # return redirect("longs:mark_question_overview", pk=exam_id, sk=n - 1) - - else: - form = MarkLongQuestionSingleForm(initial={"score": answer.score}) - - else: - if request.method == "POST": - form = MarkLongQuestionDoubleForm(request.POST) - - if form.is_valid(): - # If skip button is pressed skip the question without marking - # Skip is problematic if we skip to the next unmarked answer - # if "skip" in request.POST: - # return redirect("longs:mark_answer", pk=exam_id, sk=question_number, cid=next_unmarked_id) - - mark_object = AnswerMarks.objects.get_or_create( - user_answer=answer, marker=request.user - )[0] - # Extract score from form and save it to the object - mark_object.score = form.cleaned_data["score"] - mark_object.mark_reason = form.cleaned_data["mark_reason"] - mark_object.candidate_feedback = form.cleaned_data["candidate_feedback"] - mark_object.marker = request.user - mark_object.save() - - # Form is saved, check if we have two marks (and if they match) - if answer.mark.count() == 2: - # if they do automatically update teh scoer - marks = set(answer.mark.values_list("score", flat=True)) - if len(marks) == 1: - answer.score = marks.pop() - else: - answer.score = "" - answer.save() - - if "next" in request.POST: - return redirect( - "longs:mark_answer", - exam_id=exam_id, - question_number=question_number, - cid=next_unmarked_id, - ) - if "save" in request.POST: - return redirect( - "longs:mark_answer", - exam_id=exam_id, - question_number=question_number, - cid=cid, - ) - # elif "previous" in request.POST: - # return redirect("longs:mark_question_overview", pk=exam_id, sk=n - 1) - - else: - try: - mark_object = AnswerMarks.objects.get( - user_answer=answer, marker=request.user - ) - form = MarkLongQuestionDoubleForm( - initial={ - "score": mark_object.score, - "mark_reason": mark_object.mark_reason, - "candidate_feedback": mark_object.candidate_feedback, - } - ) - except AnswerMarks.DoesNotExist: - form = MarkLongQuestionDoubleForm() - - discrepancy_form = False - if answer.mark.count() == 2 or override: - # if they do automatically update teh scoer - marks = set(answer.mark.values_list("score", flat=True)) - if len(marks) > 1 or override: - discrepancy_form = MarkLongQuestionSingleForm( - initial={"score": answer.score} - ) - - return render( - request, - "longs/mark_answer.html", - { - "exam": exam, - "form": form, - "discrepancy_form": discrepancy_form, - "answer": answer, - "question": question, - "question_details": question_details, - "next_unmarked_id": next_unmarked_id, - "unmarked": unmarked, - "previous_cid": previous_cid, - "next_cid": next_cid, - "cid": cid, - }, - ) - - -# @user_passes_test(user_is_admin, login_url="/accounts/login") -@login_required -@user_is_long_marker -def mark_question_overview(request, exam_id, sk): - exam = get_object_or_404(Exam, pk=exam_id) - - questions = exam.exam_questions.all() - - n = sk - - question_details = { - "total": len(questions), - "current": n + 1, - } - - try: - question = questions[sk] - except IndexError: - raise Http404("Exam question does not exist") - - user_answers = question.cid_user_answers.filter(exam__id=exam_id) - - unmarked_count = user_answers.filter( - score=CidUserAnswer.ScoreOptions.UNMARKED - ).count() - - if exam.double_mark: - marker_unmarked_count = question.get_unmarked_user_answer_count( - exam.pk, marker=request.user - ) - return render( - request, - "longs/mark_question_double_overview.html", - { - "exam": exam, - "question": question, - "question_details": question_details, - "user_answers": user_answers, - "unmarked_count": unmarked_count, - "marker_unmarked_count": marker_unmarked_count, - }, - ) - else: - return render( - request, - "longs/mark_question_single_overview.html", - { - "exam": exam, - "question": question, - "question_details": question_details, - "user_answers": user_answers, - "unmarked_count": unmarked_count, - }, - ) - - -@login_required -@user_is_long_marker -def exam_scores_cid(request, pk): - exam = get_object_or_404(Exam, pk=pk) - - questions = exam.exam_questions.all() - - cids = ( - CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) - .order_by("cid") - .values_list("cid", flat=True) - .distinct() - ) - - user_answers_and_marks = defaultdict(list) - user_answers_marks = defaultdict(list) - user_answers = defaultdict(list) - user_names = {} - - by_question = defaultdict(list) - unmarked = set() - - # Loop through all candidates - for cid in cids: - # Convoluted (probably...) - user_names[cid] = cid - for q in questions: - # Get user answer - s = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() - - if not s: - # skip if no answer, (score 4) - user_answers_marks[cid].append(4) - # user_answers[cid].append("") - by_question[q].append(("", 4)) - continue - else: - ans = "" - answer_score = s.get_answer_score() - if answer_score == "": - index = exam.get_question_index(q) - unmarked.add(index) - # user_answers[cid].append(ans) - user_answers_marks[cid].append(answer_score) - # user_answers_and_marks[cid].append((ans, answer_score)) - - by_question[q].append((ans, answer_score)) - - user_scores = {} - user_scores_normalised = {} - for user in user_answers_marks: - user_scores[user] = sum([i for i in user_answers_marks[user] if i != ""]) - user_scores_normalised[user] = normaliseScore( - sum([i for i in user_answers_marks[user] if i != ""]) - ) - - user_scores_list = list(user_scores.values()) - - if len(user_scores_list) < 1: - mean = 0 - median = 0 - mode = 0 - fig_html = "" - else: - mean = statistics.mean(user_scores_list) - median = statistics.median(user_scores_list) - try: - mode = statistics.mode(user_scores_list) - except statistics.StatisticsError: - mode = "No unique mode" - - df = user_scores_list - fig = px.histogram( - df, - x=0, - title="{}: distribution of scores".format(exam), - labels={"0": "Score"}, - height=400, - width=600, - ) - fig_html = fig.to_html() - - max_score = len(questions) * 2 - - return render( - request, - "longs/exam_scores.html", - { - "cids": cids, - "exam": exam, - "unmarked": unmarked, - "questions": questions, - "by_question": by_question, - # "user_answers": dict(user_answers), - "user_answers_marks": dict(user_answers_marks), - "user_scores": user_scores, - "user_scores_normalised": user_scores_normalised, - "user_scores_list": user_scores_list, - "user_names": user_names, - # "user_answers_and_marks": user_answers_and_marks, - "max_score": max_score, - "mean": mean, - "median": median, - "mode": mode, - "plot": fig_html, - }, - ) - - -def exam_scores_cid_user(request, pk, cid, passcode): - exam = get_object_or_404(Exam, pk=pk) - - # TODO:Need some kind of test for cid - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - if not exam.check_cid_user(cid, passcode, request): - raise Http404("Error accessing exam") - - - questions = exam.exam_questions.all() - - # answers_and_marks = [] - answers_marks = [] - # answers = [] - answer_text = [] - - view_all_results = False - if request.user.groups.filter(name="view_all_results").exists(): - view_all_results = True - - for q in questions: - # Get user answer - user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() - - if not user_answer or user_answer is None: - # skip if no answer - # answers_marks.append("") - # answers.append("") - answer_score = 4 - # ans = "Not answered" - answer_text.append( - ( - ("Not answered"), - ("Not answered"), - ("Not answered"), - ("Not answered"), - ("Not answered"), - ) - ) - else: - answer_score = user_answer.get_answer_score() - - answer_text.append( - ( - user_answer.answer_observations, - user_answer.answer_interpretation, - user_answer.answer_principle_diagnosis, - user_answer.answer_differential_diagnosis, - user_answer.answer_management, - ) - ) - - if not exam.publish_results and not view_all_results: - answer_score = 0 - # answers.append(ans) - answers_marks.append(answer_score) - # answers_and_marks.append((ans, answer_score, correct_answer)) - - answered = [i for i in answers_marks if i != ""] - if "" in answers_marks: - total_score = sum(answered) - unmarked_number = len(answers_marks) - len(answered) - total_score = "{} ({} unmarked)".format(total_score, unmarked_number) - else: - total_score = sum(answered) - normalised_score = normaliseScore(total_score) - - max_score = len(questions) * 8 - - return render( - request, - "longs/exam_scores_user.html", - { - "exam": exam, - "cid": cid, - "passcode": passcode, - "questions": questions, - # "answers": answers, - "answers_marks": answers_marks, - "total_score": total_score, - "normalised_score": normalised_score, - "max_score": max_score, - "answer_text": answer_text, - # "answers_and_marks": answers_and_marks, - "view_all_results": view_all_results, - }, - ) - - -@login_required -@user_is_author_or_long_series_checker -def long_series_order_dicom(request, pk): - series = get_object_or_404(LongSeries, pk=pk) - try: - series.order_by_dicom() - except: - return HttpResponse("

Series does not appear to contain dicoms

") - - return redirect("longs:long_series_detail", pk=pk) - - -@login_required -@user_is_author_or_long_series_checker -def long_series_order_dicom_instance(request, pk): - series = get_object_or_404(LongSeries, pk=pk) - try: - series.order_by_dicom("InstanceNumber") - except: - return HttpResponse( - "

Series does not appear to contain dicoms (or field InstanceNumber)

" - ) - - return redirect("longs:long_series_detail", pk=pk) - - -@login_required -@user_is_author_or_long_series_checker -def long_series_order_dicom_SeriesInstanceUID(request, pk): - series = get_object_or_404(LongSeries, pk=pk) - try: - series.order_by_dicom("SeriesInstanceUID") - except: - return HttpResponse( - "

Series does not appear to contain dicoms (or field SeriesInstanceUID)

" - ) - - return redirect("longs:long_series_detail", pk=pk) - - -@login_required -@user_is_author_or_long_series_checker -def long_series_order_upload_filename(request, pk): - series = get_object_or_404(LongSeries, pk=pk) - series.order_by_upload_filename() - - return redirect("longs:long_series_detail", pk=pk) - - -GenericExamViews = ExamViews(Exam, Long, None, CidUserAnswer, "longs", "long", loadJsonAnswer) - - -class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - return super().form_valid(form) - - -class ExamClone(ExamCloneMixin, ExamCreate): - """Clone exam view""" - - -class ExamUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - return super().form_valid(form) - - -class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Exam - template_name = "exam_confirm_delete.html" - success_url = reverse_lazy("longs:index") - - -class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): - queryset = Exam.objects.all().order_by("name") - serializer_class = ExamSerializer - - -def question_json_unbased(request, pk): - """ - No (file based) caching is enabled for unbased quesitons - """ - question = get_object_or_404(Long, pk=pk) - - question_json = question.get_question_json(based=False) - return JsonResponse(question_json) - - -def question_json(request, pk): - question = get_object_or_404(Long, pk=pk) - # exam = get_object_or_404(self.Exam, pk=pk) - - # if not exam.active: - # raise Http404("No available exam") - - path = "{0}longs/questions/{1}.json".format(settings.MEDIA_ROOT, pk) - url = "{0}longs/questions/{1}.json".format(settings.MEDIA_URL, pk) - - if os.path.isfile(path) and not question.recreate_json: - return redirect(url) - - question_json = question.get_question_json() - - question.recreate_json = False - question.save() - - return redirect(question_json) - - -@login_required -@user_is_author_or_long_checker -def question_json_recreate(request, pk): - question = get_object_or_404(Long, pk=pk) - - question.recreate_json = True - question.save() - - # We also have to invalidate any exams - - for exam in question.exams.all(): - exam.recreate_json = True - exam.save() - - # question.get_question_json() - - return redirect("longs:question_detail", pk=pk) - - -class UserAnswerView(LoginRequiredMixin, DetailView): - model = CidUserAnswer - - -class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = CidUserAnswer - table_class = UserAnswerTable - template_name = "longs/user_answer_question_view.html" - - filterset_class = UserAnswerFilter - - -class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): - model = CidUserAnswer - template_name = "user_answer_delete.html" - success_url = reverse_lazy("longs:user_answer_table_view") - - -@user_passes_test(lambda u: u.is_superuser) -def user_answer_delete_multiple(request): - if request.is_ajax(): - answer_ids = json.loads(request.POST["answer_ids"]) - - # We could probably delete them all at once.... - for id in answer_ids: - CidUserAnswer.objects.get(pk=id).delete() - return JsonResponse({"status": "success"}) - return JsonResponse({"status": "error"}) - - -@user_passes_test(lambda u: u.is_superuser) -def refresh_exam_question_json(request, pk): - exam = Exam.objects.get(pk=pk) - - for question in exam.exam_questions.all(): - question.recreate_json = True - question.save() - - return redirect("longs:exam_overview", pk=pk) - - -class SeriesImagesZipView(SuperuserRequiredMixin, BaseZipView): - """Download all images from an image series""" - - def get_files(self): - series = LongSeries.objects.get(pk=self.kwargs["pk"]) - - return [i.image.file for i in series.images.all()] - - -def question_review(request, pk): - """ - Return a json representation of the question when the exam is active - """ - if request.is_ajax(): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.publish_results: - raise Http404 - - question = exam.exam_questions.all()[int(request.POST["question_number"])] - - question_json = question.get_question_json(based=False) - return JsonResponse(question_json) - return JsonResponse({"status": "error"}) +import json +from django.shortcuts import render, get_object_or_404, redirect +from django import forms + +# from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User +from django.core.exceptions import ( + ObjectDoesNotExist, + PermissionDenied, + ViewDoesNotExist, +) + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic.detail import DetailView +from generic.mixins import SuperuserRequiredMixin + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView +from django.views.decorators.csrf import csrf_exempt + +from django.urls import reverse_lazy, reverse + +from django.http import Http404, JsonResponse +from django.http import HttpResponseRedirect, HttpResponse + +from .forms import ( + LongForm, + LongSeriesForm, + LongSeriesImageFormSet, + MarkLongQuestionDoubleForm, + MarkLongQuestionSingleForm, + SeriesFormSet, + ExaminationForm, + ExamForm, +) +from .models import ( + AnswerMarks, + Long, + LongSeries, + Examination, + Exam, + CidUserAnswer, +) +from .tables import LongTable, LongSeriesTable, UserAnswerTable +from .filters import LongFilter, LongSeriesFilter, UserAnswerFilter + +from django_tables2 import SingleTableView, SingleTableMixin +from django_filters.views import FilterView + +from .decorators import ( + user_is_author_or_long_checker, + user_is_author_or_long_checker_or_long_marker, + user_is_author_or_long_series_checker_or_long_marker, + user_is_long_checker, + user_is_author_or_long_series_checker, + user_is_long_marker, + user_is_author_or_long_checker_or_long_marker, +) + +from collections import defaultdict +import json +import statistics +import plotly.express as px + +from django.core.cache import cache +from django.conf import settings + +from helpers.images import image_as_base64 + +import logging +from copy import deepcopy +from django.forms.models import model_to_dict +from longs.forms import LongCreationDefaultForm +from longs.models import LongCreationDefault + +from generic.views import ExamCloneMixin, ExamViews +from reversion.views import RevisionMixin +import reversion + + +from rest_framework import viewsets + +from .serializers import ExamSerializer + +from zipview.views import BaseZipView + +import os + +logger = logging.getLogger(__name__) + + +def normaliseScore(score): + if score >= 25.5 and score <= 28: + return 4.5 + elif score >= 28.5 and score <= 31: + return 5 + elif score >= 31.5 and score <= 34: + return 5.5 + elif score >= 34.5 and score <= 37: + return 6 + elif score >= 37.5 and score <= 40: + return 6.5 + elif score >= 40.5 and score <= 43: + return 7 + elif score >= 43.5 and score <= 46: + return 7.5 + elif score >= 46.5 and score <= 48: + return 7.5 + + return 4 + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if ( + self.request.user.groups.filter(name="long_checker").exists() + or self.request.user.is_superuser + ): + return obj + if self.request.user not in obj.get_author_objects(): + raise PermissionDenied() # or Http404 + return obj + + +# def index(request): +# other_longs = Long.objects.exclude(author=request.user.pk) +# user_longs = Long.objects.filter(author=request.user.pk) +# return render( +# request, +# "longs/index.html", +# {"other_longs": other_longs, "user_longs": user_longs}, +# ) + + +@login_required +@user_is_author_or_long_checker_or_long_marker +def question_detail(request, pk): + long = get_object_or_404(Long, pk=pk) + + # if request.user not in long.author.all(): + # raise PermissionDenied + + # logging.debug(long.subspecialty.first().name.all()) + return render(request, "longs/question_detail.html", {"question": long}) + + +@login_required +@user_is_author_or_long_series_checker_or_long_marker +def long_series_detail(request, pk): + series = get_object_or_404(LongSeries, pk=pk) + + # if request.user not in long.author.all(): + # raise PermissionDenied + + # logging.debug(long.subspecialty.first().name.all()) + return render(request, "longs/long_series.html", {"series": series}) + + +@login_required +@user_is_author_or_long_checker +def long_split(request, pk): + long = get_object_or_404(Long, pk=pk) + + images = long.images.all() + + old_abnormality = long.abnormality.all() + old_region = long.region.all() + old_examination = long.examination.all() + old_site = long.site.all() + old_author = long.author.all() + + if not images: + raise Http404 + + for n in range(len(images) - 1): + long.pk = None + + long.save() + + long.abnormality.set(old_abnormality) + long.region.set(old_region) + long.examination.set(old_examination) + long.site.set(old_site) + long.author.set(old_author) + images[n].long = long + images[n].save() + + # images[-1].long + + # if request.user not in long.author.all(): + # raise PermissionDenied + + # logging.debug(long.subspecialty.first().name.all()) + return render(request, "longs/question_detail.html", {"question": long}) + + +@login_required +@user_is_author_or_long_checker +def author_detail(request, pk): + # logging.debug(Author.objects.all()) + # author = get_object_or_404(Author, pk=pk) + author = User.objects.get(pk=pk) + + longs = Long.objects.filter(author=pk) + + return render( + request, "longs/category_detail.html", {"category": author, "longs": longs} + ) + + +@user_is_author_or_long_checker +def author_list(request): + authors = User.objects.all() + + return render(request, "longs/author_list.html", {"authors": authors}) + + +class LongCreationDefaultView(LoginRequiredMixin, UpdateView): + model = LongCreationDefault + form_class = LongCreationDefaultForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + # def dispatch(self, request, *args, **kwargs): + # """ + # Overridden so we can make sure the `Long` instance exists + # before going any further. + # """ + # self.pk = get_object_or_404(Long, pk=kwargs['pk']) + # return super().dispatch(request, *args, **kwargs) + def get_object(self, queryset=None): + obj, create = LongCreationDefault.objects.get_or_create( + author=self.request.user + ) + + return obj + + def form_valid(self, form): + + model = form.save(commit=False) + + model.author = self.request.user + model.save() + + response = super().form_valid(form) + + return response + + # form.instance.author.add(self.request.user.id) + + +class LongDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Long + success_url = reverse_lazy("longs:long_view") + + +class LongSeriesDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = LongSeries + success_url = reverse_lazy("longs:long_series_view") + + +@login_required +@user_is_author_or_long_checker +def long_clone(request, pk): + new_item = get_object_or_404(Long, pk=pk) + new_item.pk = None # autogen a new pk (item_id) + # new_item.name = "Copy of " + new_item.name #need to change uniques + + form = LongForm(request.POST or None, instance=new_item) + + series_formset = SeriesFormSet() + + if form.is_valid(): + form.instance.author.add(request.user.id) + + # logger.debug(formset.is_valid()) + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = obj + series_formset.save() + + return response + else: + return super().form_invalid(form) + + context = { + "form": form, + "series_formset": series_formset, + # other context + } + + return render(request, "longs/long_form.html", context) + + +class LongSeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = LongSeries + form_class = LongSeriesForm + + def get_initial(self): + # print(self.request) + if "pk" in self.kwargs: + initial = super().get_initial() + long = get_object_or_404(Long, pk=self.kwargs["pk"]) + + initial["long"] = long.id + + return initial + + def get_form_kwargs(self): + kwargs = super(LongSeriesCreate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(LongSeriesCreate, self).get_context_data(**kwargs) + + if self.request.POST: + context["image_formset"] = LongSeriesImageFormSet( + self.request.POST, self.request.FILES + ) + context["image_formset"].full_clean() + else: + context["image_formset"] = LongSeriesImageFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["image_formset"] + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + return response + + else: + return super().form_invalid(form) + + +class LongSeriesUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = LongSeries + form_class = LongSeriesForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + + def get_form_kwargs(self): + kwargs = super(LongSeriesUpdate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(LongSeriesUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["image_formset"] = LongSeriesImageFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["image_formset"].full_clean() + else: + context["image_formset"] = LongSeriesImageFormSet(instance=self.object) + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + # Do this in the form.save() + # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) + # self.object.exams.clear() + # self.object.exams.add(*new_exams) + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + image_formset = context["image_formset"] + # logger.debug(formset.is_valid()) + if image_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_formset.save() + return response + else: + return super().form_invalid(form) + + +class LongCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): + model = Long + form_class = LongForm + + def get_form_kwargs(self): + kwargs = super(LongCreateBase, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(LongCreateBase, self).get_context_data(**kwargs) + if self.request.POST: + context["series_formset"] = SeriesFormSet( + self.request.POST, self.request.FILES + ) + context["series_formset"].full_clean() + else: + context["series_formset"] = SeriesFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + + # Add exam objects + + self.object.save() + + # Do this in the form.save() + # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) + # self.object.exams.clear() + # self.object.exams.add(*new_exams) + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["series_formset"] + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + # If the normal submit button is pressed we save as normal + if "submit" in self.request.POST: + return response + # else we redirect to the clone url + else: + return redirect("longs:long_clone", pk=self.object.pk) + + else: + return super().form_invalid(form) + + +# @login_required +class LongCreate(LongCreateBase): + def get_initial(self): + # There has to be a better way... + try: + s = (i.pk for i in self.request.user.long_default.site.all()) + self.initial.update({"site": s}) + except AttributeError: + pass + return self.initial + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + + # self.object = form.save(commit=False) + # self.object.save() + + # form.instance.author.add(self.request.user.id) + # return super().form_valid(form) + + +class LongUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Long + form_class = LongForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + + def get_form_kwargs(self): + kwargs = super(LongUpdate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(LongUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["series_formset"] = SeriesFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["series_formset"].full_clean() + else: + context["series_formset"] = SeriesFormSet(instance=self.object) + return context + + def form_valid(self, form): + # save exam orders (there must be a better way to do this) + exam_orders = {} + for exam in self.object.exams.all(): + exam_orders[exam] = list(exam.exam_questions.all()) + + self.object = form.save(commit=False) + self.object.save() + + # Do this in the form.save() + # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) + # self.object.exams.clear() + # self.object.exams.add(*new_exams) + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["series_formset"] + # logger.debug(formset.is_valid()) + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + + # restore exam orders + for exam in self.object.exams.all(): + if exam in exam_orders and self.object in exam_orders[exam]: + print(exam_orders[exam]) + exam.exam_questions.set(exam_orders[exam]) + exam.save() + + return response + else: + return super().form_invalid(form) + + +class LongClone(LongCreateBase): + """Clones a existing long""" + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_initial(self): + # print(self.request) + old_object = get_object_or_404(Long, pk=self.kwargs["pk"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + return initial_data + + +@login_required +@user_is_author_or_long_checker +def long_scrap(request, pk): + try: + long = Long.objects.get(pk=pk) + except Long.DoesNotExist: + raise Http404("Long does not exist") + + long.scrapped = not long.scrapped + long.save() + return HttpResponseRedirect(reverse("longs:question_detail", args=(pk,))) + + +# @login_required +# def edit_abnormality_popup(request): +# instance = get_object_or_404(Abnormality, pk = pk) +# form = AbnormalityForm(request.POST or None) +# if form.is_valid(): +# instance = form.save() +# return HttpResponse('' % (instance.pk, instance)) +# return render(request, "longs/create_simple.html", {'form': form}) + + +@login_required +def create_examination(request): + form = ExaminationForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "longs/create_simple.html", {"form": form, "name": "Examination"} + ) + + +@csrf_exempt +def get_examination_id(request): + if request.is_ajax(): + examination_name = request.GET["examination_name"] + examination_id = Examination.objects.get(name=examination_name).id + data = { + "examination_id": examination_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +class LongView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Long + table_class = LongTable + template_name = "longs/view.html" + + filterset_class = LongFilter + + +class LongSeriesView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = LongSeries + table_class = LongSeriesTable + template_name = "longs/view.html" + + filterset_class = LongSeriesFilter + + +def loadJsonAnswer(answer): + eid = int(answer["eid"].split("/")[1]) + cid = int(answer["cid"]) + + # As access is not restricted make sure the data appears valid + if (not isinstance(cid, int)) or ( + not isinstance(eid, int) or (not isinstance(answer["ans"], str)) + ): + return False, JsonResponse( + {"success": False, "error": "cid or eid or answers not defined"} + ) + + # The model should catch invalid data but this should be less intensive + max_int = 999999999999999999999 + if cid > max_int or eid > max_int: + return False, JsonResponse({"success": False, "error": "invalid cid"}) + + exam = get_object_or_404(Exam, pk=eid) + + if not exam.check_cid_user(cid, answer["passcode"]): + return False, JsonResponse( + {"success": False, "error": "invalid cid / passcode"} + ) + + # if not exam.active: + # return False, JsonResponse( + # {"success": False, "error": "No active exam: {}".format(eid)} + # ) + + exiting_answers = CidUserAnswer.objects.filter( + question__id=answer["qid"], exam__id=eid, cid=cid + ) + + posted_answer = answer["ans"] + + # Long cases seperate sections by qidn + qidn = answer["qidn"] + + # If the user answer does not exist + if not exiting_answers: + ans = CidUserAnswer(cid=cid) + ans.question_id = answer["qid"] + ans.exam_id = eid + + # If the answer already exists or we have started populating it + else: + # Update an existing answer + # should never be more than one (famous last words) + ans = exiting_answers[0] + + if qidn == "1": + ans.answer_observations = posted_answer + elif qidn == "2": + ans.answer_interpretation = posted_answer + elif qidn == "3": + ans.answer_principle_diagnosis = posted_answer + elif qidn == "4": + ans.answer_differential_diagnosis = posted_answer + elif qidn == "5": + ans.answer_management = posted_answer + + # Mark as unmarked + ans.score = ans.ScoreOptions.UNMARKED + + ans.full_clean() + ans.save() + + return True, None + + +@user_is_long_marker +@reversion.create_revision() +def mark_answer_override(request, exam_id, question_number, cid): + return mark_answer(request, exam_id, question_number, cid, override=True) + + +@user_is_long_marker +@reversion.create_revision() +def mark_answer(request, exam_id, question_number, cid, override=False): + exam = get_object_or_404(Exam, pk=exam_id) + + questions = exam.exam_questions.all() + + n = question_number + + question_details = { + "total": len(questions), + "current": n + 1, + } + + try: + question = questions[question_number] + except IndexError: + raise Http404("Exam question does not exist") + + try: + answer = question.cid_user_answers.get(cid=cid, exam__id=exam_id) + except ObjectDoesNotExist: + raise Http404("User answer does not exist") + + cid_list = list( + question.cid_user_answers.filter(exam__id=exam_id).values_list("cid", flat=True) + ) + + previous_cid = False + next_cid = False + + if len(cid_list) > 1: + if cid_list[0] == cid: + next_cid = cid_list[1] + elif cid_list[-1] == cid: + previous_cid = cid_list[-2] + else: + next_cid = cid_list[cid_list.index(cid) + 1] + previous_cid = cid_list[cid_list.index(cid) - 1] + + try: + if exam.double_mark: + unmarked = question.get_unmarked_user_answers( + exam_pk=exam.id, marker=request.user + ) + else: + unmarked = question.get_unmarked_user_answers(exam_pk=exam.id) + next_unmarked_id = unmarked[0].cid + if next_unmarked_id == cid: + next_unmarked_id = unmarked[1].cid + except IndexError: + next_unmarked_id = False + + # We use different forms if the exam should be single or double marked + + if not exam.double_mark or ( + request.method == "POST" and request.POST["form_id"] == "discrepancy_form" + ): + if request.method == "POST": + form = MarkLongQuestionSingleForm(request.POST) + + if form.is_valid(): + # If skip button is pressed skip the question without marking + # Skip is problematic if we skip to the next unmarked answer + # if "skip" in request.POST: + # return redirect("longs:mark_answer", pk=exam_id, sk=question_number, cid=next_unmarked_id) + + # Extract score from form and save it to the object + answer.score = form.cleaned_data["score"] + answer.save() + + if "next" in request.POST: + return redirect( + "longs:mark_answer", + exam_id=exam_id, + question_number=question_number, + cid=next_unmarked_id, + ) + if "save" in request.POST: + return redirect( + "longs:mark_answer", + exam_id=exam_id, + question_number=question_number, + cid=cid, + ) + # elif "previous" in request.POST: + # return redirect("longs:mark_question_overview", pk=exam_id, sk=n - 1) + + else: + form = MarkLongQuestionSingleForm(initial={"score": answer.score}) + + else: + if request.method == "POST": + form = MarkLongQuestionDoubleForm(request.POST) + + if form.is_valid(): + # If skip button is pressed skip the question without marking + # Skip is problematic if we skip to the next unmarked answer + # if "skip" in request.POST: + # return redirect("longs:mark_answer", pk=exam_id, sk=question_number, cid=next_unmarked_id) + + mark_object = AnswerMarks.objects.get_or_create( + user_answer=answer, marker=request.user + )[0] + # Extract score from form and save it to the object + mark_object.score = form.cleaned_data["score"] + mark_object.mark_reason = form.cleaned_data["mark_reason"] + mark_object.candidate_feedback = form.cleaned_data["candidate_feedback"] + mark_object.marker = request.user + mark_object.save() + + # Form is saved, check if we have two marks (and if they match) + if answer.mark.count() == 2: + # if they do automatically update teh scoer + marks = set(answer.mark.values_list("score", flat=True)) + if len(marks) == 1: + answer.score = marks.pop() + else: + answer.score = "" + answer.save() + + if "next" in request.POST: + return redirect( + "longs:mark_answer", + exam_id=exam_id, + question_number=question_number, + cid=next_unmarked_id, + ) + if "save" in request.POST: + return redirect( + "longs:mark_answer", + exam_id=exam_id, + question_number=question_number, + cid=cid, + ) + # elif "previous" in request.POST: + # return redirect("longs:mark_question_overview", pk=exam_id, sk=n - 1) + + else: + try: + mark_object = AnswerMarks.objects.get( + user_answer=answer, marker=request.user + ) + form = MarkLongQuestionDoubleForm( + initial={ + "score": mark_object.score, + "mark_reason": mark_object.mark_reason, + "candidate_feedback": mark_object.candidate_feedback, + } + ) + except AnswerMarks.DoesNotExist: + form = MarkLongQuestionDoubleForm() + + discrepancy_form = False + if answer.mark.count() == 2 or override: + # if they do automatically update teh scoer + marks = set(answer.mark.values_list("score", flat=True)) + if len(marks) > 1 or override: + discrepancy_form = MarkLongQuestionSingleForm( + initial={"score": answer.score} + ) + + return render( + request, + "longs/mark_answer.html", + { + "exam": exam, + "form": form, + "discrepancy_form": discrepancy_form, + "answer": answer, + "question": question, + "question_details": question_details, + "next_unmarked_id": next_unmarked_id, + "unmarked": unmarked, + "previous_cid": previous_cid, + "next_cid": next_cid, + "cid": cid, + }, + ) + + +# @user_passes_test(user_is_admin, login_url="/accounts/login") +@login_required +@user_is_long_marker +def mark_question_overview(request, exam_id, sk): + exam = get_object_or_404(Exam, pk=exam_id) + + questions = exam.exam_questions.all() + + n = sk + + question_details = { + "total": len(questions), + "current": n + 1, + } + + try: + question = questions[sk] + except IndexError: + raise Http404("Exam question does not exist") + + user_answers = question.cid_user_answers.filter(exam__id=exam_id) + + unmarked_count = user_answers.filter( + score=CidUserAnswer.ScoreOptions.UNMARKED + ).count() + + if exam.double_mark: + marker_unmarked_count = question.get_unmarked_user_answer_count( + exam.pk, marker=request.user + ) + return render( + request, + "longs/mark_question_double_overview.html", + { + "exam": exam, + "question": question, + "question_details": question_details, + "user_answers": user_answers, + "unmarked_count": unmarked_count, + "marker_unmarked_count": marker_unmarked_count, + }, + ) + else: + return render( + request, + "longs/mark_question_single_overview.html", + { + "exam": exam, + "question": question, + "question_details": question_details, + "user_answers": user_answers, + "unmarked_count": unmarked_count, + }, + ) + + +@login_required +@user_is_long_marker +def exam_scores_cid(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + questions = exam.exam_questions.all() + + cids = ( + CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) + .order_by("cid") + .values_list("cid", flat=True) + .distinct() + ) + + user_answers_and_marks = defaultdict(list) + user_answers_marks = defaultdict(list) + user_answers = defaultdict(list) + user_names = {} + + by_question = defaultdict(list) + unmarked = set() + + # Loop through all candidates + for cid in cids: + # Convoluted (probably...) + user_names[cid] = cid + for q in questions: + # Get user answer + s = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() + + if not s: + # skip if no answer, (score 4) + user_answers_marks[cid].append(4) + # user_answers[cid].append("") + by_question[q].append(("", 4)) + continue + else: + ans = "" + answer_score = s.get_answer_score() + if answer_score == "": + index = exam.get_question_index(q) + unmarked.add(index) + # user_answers[cid].append(ans) + user_answers_marks[cid].append(answer_score) + # user_answers_and_marks[cid].append((ans, answer_score)) + + by_question[q].append((ans, answer_score)) + + user_scores = {} + user_scores_normalised = {} + for user in user_answers_marks: + user_scores[user] = sum([i for i in user_answers_marks[user] if i != ""]) + user_scores_normalised[user] = normaliseScore( + sum([i for i in user_answers_marks[user] if i != ""]) + ) + + user_scores_list = list(user_scores.values()) + + if len(user_scores_list) < 1: + mean = 0 + median = 0 + mode = 0 + fig_html = "" + else: + mean = statistics.mean(user_scores_list) + median = statistics.median(user_scores_list) + try: + mode = statistics.mode(user_scores_list) + except statistics.StatisticsError: + mode = "No unique mode" + + df = user_scores_list + fig = px.histogram( + df, + x=0, + title="{}: distribution of scores".format(exam), + labels={"0": "Score"}, + height=400, + width=600, + ) + fig_html = fig.to_html() + + max_score = len(questions) * 2 + + return render( + request, + "longs/exam_scores.html", + { + "cids": cids, + "exam": exam, + "unmarked": unmarked, + "questions": questions, + "by_question": by_question, + # "user_answers": dict(user_answers), + "user_answers_marks": dict(user_answers_marks), + "user_scores": user_scores, + "user_scores_normalised": user_scores_normalised, + "user_scores_list": user_scores_list, + "user_names": user_names, + # "user_answers_and_marks": user_answers_and_marks, + "max_score": max_score, + "mean": mean, + "median": median, + "mode": mode, + "plot": fig_html, + }, + ) + + +def exam_scores_cid_user(request, pk, cid, passcode): + exam = get_object_or_404(Exam, pk=pk) + + # TODO:Need some kind of test for cid + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + if not exam.check_cid_user(cid, passcode, request): + raise Http404("Error accessing exam") + + + questions = exam.exam_questions.all() + + # answers_and_marks = [] + answers_marks = [] + # answers = [] + answer_text = [] + + view_all_results = False + if request.user.groups.filter(name="view_all_results").exists(): + view_all_results = True + + for q in questions: + # Get user answer + user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() + + if not user_answer or user_answer is None: + # skip if no answer + # answers_marks.append("") + # answers.append("") + answer_score = 4 + # ans = "Not answered" + answer_text.append( + ( + ("Not answered"), + ("Not answered"), + ("Not answered"), + ("Not answered"), + ("Not answered"), + ) + ) + else: + answer_score = user_answer.get_answer_score() + + answer_text.append( + ( + user_answer.answer_observations, + user_answer.answer_interpretation, + user_answer.answer_principle_diagnosis, + user_answer.answer_differential_diagnosis, + user_answer.answer_management, + ) + ) + + if not exam.publish_results and not view_all_results: + answer_score = 0 + # answers.append(ans) + answers_marks.append(answer_score) + # answers_and_marks.append((ans, answer_score, correct_answer)) + + answered = [i for i in answers_marks if i != ""] + if "" in answers_marks: + total_score = sum(answered) + unmarked_number = len(answers_marks) - len(answered) + total_score = "{} ({} unmarked)".format(total_score, unmarked_number) + else: + total_score = sum(answered) + normalised_score = normaliseScore(total_score) + + max_score = len(questions) * 8 + + return render( + request, + "longs/exam_scores_user.html", + { + "exam": exam, + "cid": cid, + "passcode": passcode, + "questions": questions, + # "answers": answers, + "answers_marks": answers_marks, + "total_score": total_score, + "normalised_score": normalised_score, + "max_score": max_score, + "answer_text": answer_text, + # "answers_and_marks": answers_and_marks, + "view_all_results": view_all_results, + }, + ) + + +@login_required +@user_is_author_or_long_series_checker +def long_series_order_dicom(request, pk): + series = get_object_or_404(LongSeries, pk=pk) + try: + series.order_by_dicom() + except: + return HttpResponse("

Series does not appear to contain dicoms

") + + return redirect("longs:long_series_detail", pk=pk) + + +@login_required +@user_is_author_or_long_series_checker +def long_series_order_dicom_instance(request, pk): + series = get_object_or_404(LongSeries, pk=pk) + try: + series.order_by_dicom("InstanceNumber") + except: + return HttpResponse( + "

Series does not appear to contain dicoms (or field InstanceNumber)

" + ) + + return redirect("longs:long_series_detail", pk=pk) + + +@login_required +@user_is_author_or_long_series_checker +def long_series_order_dicom_SeriesInstanceUID(request, pk): + series = get_object_or_404(LongSeries, pk=pk) + try: + series.order_by_dicom("SeriesInstanceUID") + except: + return HttpResponse( + "

Series does not appear to contain dicoms (or field SeriesInstanceUID)

" + ) + + return redirect("longs:long_series_detail", pk=pk) + + +@login_required +@user_is_author_or_long_series_checker +def long_series_order_upload_filename(request, pk): + series = get_object_or_404(LongSeries, pk=pk) + series.order_by_upload_filename() + + return redirect("longs:long_series_detail", pk=pk) + + +GenericExamViews = ExamViews(Exam, Long, None, CidUserAnswer, "longs", "long", loadJsonAnswer) + + +class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + return super().form_valid(form) + + +class ExamClone(ExamCloneMixin, ExamCreate): + """Clone exam view""" + + +class ExamUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + return super().form_valid(form) + + +class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Exam + template_name = "exam_confirm_delete.html" + success_url = reverse_lazy("longs:index") + + +class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): + queryset = Exam.objects.all().order_by("name") + serializer_class = ExamSerializer + + +def question_json_unbased(request, pk): + """ + No (file based) caching is enabled for unbased quesitons + """ + question = get_object_or_404(Long, pk=pk) + + question_json = question.get_question_json(based=False) + return JsonResponse(question_json) + + +def question_json(request, pk): + question = get_object_or_404(Long, pk=pk) + # exam = get_object_or_404(self.Exam, pk=pk) + + # if not exam.active: + # raise Http404("No available exam") + + path = "{0}longs/questions/{1}.json".format(settings.MEDIA_ROOT, pk) + url = "{0}longs/questions/{1}.json".format(settings.MEDIA_URL, pk) + + if os.path.isfile(path) and not question.recreate_json: + return redirect(url) + + question_json = question.get_question_json() + + question.recreate_json = False + question.save() + + return redirect(question_json) + + +@login_required +@user_is_author_or_long_checker +def question_json_recreate(request, pk): + question = get_object_or_404(Long, pk=pk) + + question.recreate_json = True + question.save() + + # We also have to invalidate any exams + + for exam in question.exams.all(): + exam.recreate_json = True + exam.save() + + # question.get_question_json() + + return redirect("longs:question_detail", pk=pk) + + +class UserAnswerView(LoginRequiredMixin, DetailView): + model = CidUserAnswer + + +class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = CidUserAnswer + table_class = UserAnswerTable + template_name = "longs/user_answer_question_view.html" + + filterset_class = UserAnswerFilter + + +class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): + model = CidUserAnswer + template_name = "user_answer_delete.html" + success_url = reverse_lazy("longs:user_answer_table_view") + + +@user_passes_test(lambda u: u.is_superuser) +def user_answer_delete_multiple(request): + if request.is_ajax(): + answer_ids = json.loads(request.POST["answer_ids"]) + + # We could probably delete them all at once.... + for id in answer_ids: + CidUserAnswer.objects.get(pk=id).delete() + return JsonResponse({"status": "success"}) + return JsonResponse({"status": "error"}) + + +@user_passes_test(lambda u: u.is_superuser) +def refresh_exam_question_json(request, pk): + exam = Exam.objects.get(pk=pk) + + for question in exam.exam_questions.all(): + question.recreate_json = True + question.save() + + return redirect("longs:exam_overview", pk=pk) + + +class SeriesImagesZipView(SuperuserRequiredMixin, BaseZipView): + """Download all images from an image series""" + + def get_files(self): + series = LongSeries.objects.get(pk=self.kwargs["pk"]) + + return [i.image.file for i in series.images.all()] + + +def question_review(request, pk): + """ + Return a json representation of the question when the exam is active + """ + if request.is_ajax(): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.publish_results: + raise Http404 + + question = exam.exam_questions.all()[int(request.POST["question_number"])] + + question_json = question.get_question_json(based=False) + return JsonResponse(question_json) + return JsonResponse({"status": "error"}) diff --git a/rad/settings.py b/rad/settings.py index 1cda6d7a..2975a1ec 100644 --- a/rad/settings.py +++ b/rad/settings.py @@ -1,268 +1,268 @@ -""" -Django settings for rad project. - -Generated by 'django-admin startproject' using Django 1.11.14. - -For more information on this file, see -https://docs.djangoproject.com/en/1.11/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.11/ref/settings/ -""" - -import os - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -with open("/etc/secret_key") as f: - SECRET_KEY = f.read().strip() - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [ - "localhost", - "127.0.0.1", - "161.35.163.87", - "penracourses.org.uk", - "www.penracourses.org.uk", -] - -# Application definition - -INSTALLED_APPS = [ - "generic", - "anatomy", - "physics", - "rapids", - "longs", - "sbas", - "wally", - "atlas", - "reversion", - "django_tables2", - "django_filters", - "easy_thumbnails", - "filer", - "mptt", - "sortedm2m", - "dal", - "dal_select2", - "dal_queryset_sequence", - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "debug_toolbar", - "tagulous", - "dbbackup", - "rest_framework", - "tinymce", -] - -MIDDLEWARE = [ - "corsheaders.middleware.CorsMiddleware", - "django.middleware.security.SecurityMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "reversion.middleware.RevisionMiddleware", - "debug_toolbar.middleware.DebugToolbarMiddleware", -] - -ROOT_URLCONF = "rad.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(BASE_DIR, "templates")], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - "django.template.context_processors.static", - ], - }, - }, -] - -WSGI_APPLICATION = "rad.wsgi.application" - -# Database -# https://docs.djangoproject.com/en/1.11/ref/settings/#databases - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "rad", - "USER": "django", - "PASSWORD": "f7bf31dc9bda1256ea827953480d1917", - # "HOST": "xkjq.uk", - "HOST": "161.35.163.87", - "PORT": "5432", - } -} - -# Password validation -# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - -SERIALIZATION_MODULES = { - "xml": "tagulous.serializers.xml_serializer", - "json": "tagulous.serializers.json", - "python": "tagulous.serializers.python", - "yaml": "tagulous.serializers.pyyaml", -} - -# Internationalization -# https://docs.djangoproject.com/en/1.11/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "Europe/London" - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.11/howto/static-files/ - -SECURE_SSL_REDIRECT = True -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") - -STATIC_URL = "/static/" -STATIC_ROOT = "static/" - -REMOTE_URL = "https://www.penracourses.org.uk" -# -# if DEBUG: -# STATIC_URL = REMOTE_URL + STATIC_URL - -MEDIA_URL = "/media/" -MEDIA_ROOT = "/home/django/rad/media/" - -# if DEBUG: -# MEDIA_URL = REMOTE_URL + MEDIA_URL - -# Redirect to home URL after login (Default redirects to /accounts/profile/) -LOGIN_REDIRECT_URL = "/" -LOGOUT_REDIRECT_URL = "/" - -TAGULOUS_AUTOCOMPLETE_JS = ( - "tagulous/lib/jquery.js", - "tagulous/lib/select2-3/select2.min.js", - "tagulous/tagulous.js", - "tagulous/adaptor/select2-3.js", -) - -TAGULOUS_AUTOCOMPLETE_CSS = {"all": ["tagulous/lib/select2-3/select2.css"]} - -INTERNAL_IPS = ["localhost", "127.0.0.1"] - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "incremental": True, - "root": { - "level": "DEBUG", - }, -} - -CORS_ORIGIN_ALLOW_ALL = True - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", - "LOCATION": "127.0.0.1:11211", - #'OPTIONS': { - # 'server_max_value_length': 1024 * 1024 * 10 - # } - }, - "filesystem": { - "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/var/tmp/django_cache", - }, -} - -THUMBNAIL_ALIASES = { - "": { - "exam-list": {"size": (50, 50), "crop": True}, - }, -} - - -DATA_UPLOAD_MAX_NUMBER_FIELDS = 50000 - -THUMBNAIL_SOURCE_GENERATORS = ( - "easy_thumbnails.source_generators.pil_image", - "helpers.images.pil_dicom_image", -) -DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage" -DBBACKUP_STORAGE_OPTIONS = {"location": os.path.join(BASE_DIR, "../backups")} - -DEFAULT_FILE_STORAGE = ( - "django_hashedfilenamestorage.storage.HashedFilenameFileSystemStorage" -) - -TINYMCE_DEFAULT_CONFIG = { - "theme": "silver", - "height": 500, - "menubar": False, - "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor," - "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste," - "code,help,wordcount", - "toolbar": "undo redo | formatselect | code" - "bold italic backcolor | alignleft aligncenter " - "alignright alignjustify | bullist numlist outdent indent | " - "removeformat | code | help", - "skin": "oxide-dark", - "menubar": "tools", - "content_css": "dark", -} - -DEFAULT_AUTO_FIELD = "django.db.models.AutoField" - -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" - -EMAIL_HOST = "smtp.office365.com " -EMAIL_HOST_USER = "Penra.courses@nhs.net" -EMAIL_HOST_PASSWORD = '"got3<}%o"~\J",\Di3<' -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -EMAIL_USE_SSl = False -DEFAULT_FROM_EMAIL = EMAIL_HOST_USER - -try: - from .settings_local import * -except ImportError: - pass +""" +Django settings for rad project. + +Generated by 'django-admin startproject' using Django 1.11.14. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +with open("/etc/secret_key") as f: + SECRET_KEY = f.read().strip() + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [ + "localhost", + "127.0.0.1", + "161.35.163.87", + "penracourses.org.uk", + "www.penracourses.org.uk", +] + +# Application definition + +INSTALLED_APPS = [ + "generic", + "anatomy", + "physics", + "rapids", + "longs", + "sbas", + "wally", + "atlas", + "reversion", + "django_tables2", + "django_filters", + "easy_thumbnails", + "filer", + "mptt", + "sortedm2m", + "dal", + "dal_select2", + "dal_queryset_sequence", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "debug_toolbar", + "tagulous", + "dbbackup", + "rest_framework", + "tinymce", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "reversion.middleware.RevisionMiddleware", + "debug_toolbar.middleware.DebugToolbarMiddleware", +] + +ROOT_URLCONF = "rad.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.static", + ], + }, + }, +] + +WSGI_APPLICATION = "rad.wsgi.application" + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "rad", + "USER": "django", + "PASSWORD": "f7bf31dc9bda1256ea827953480d1917", + # "HOST": "xkjq.uk", + "HOST": "161.35.163.87", + "PORT": "5432", + } +} + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +SERIALIZATION_MODULES = { + "xml": "tagulous.serializers.xml_serializer", + "json": "tagulous.serializers.json", + "python": "tagulous.serializers.python", + "yaml": "tagulous.serializers.pyyaml", +} + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "Europe/London" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +SECURE_SSL_REDIRECT = True +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +STATIC_URL = "/static/" +STATIC_ROOT = "static/" + +REMOTE_URL = "https://www.penracourses.org.uk" +# +# if DEBUG: +# STATIC_URL = REMOTE_URL + STATIC_URL + +MEDIA_URL = "/media/" +MEDIA_ROOT = "/home/django/rad/media/" + +# if DEBUG: +# MEDIA_URL = REMOTE_URL + MEDIA_URL + +# Redirect to home URL after login (Default redirects to /accounts/profile/) +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/" + +TAGULOUS_AUTOCOMPLETE_JS = ( + "tagulous/lib/jquery.js", + "tagulous/lib/select2-3/select2.min.js", + "tagulous/tagulous.js", + "tagulous/adaptor/select2-3.js", +) + +TAGULOUS_AUTOCOMPLETE_CSS = {"all": ["tagulous/lib/select2-3/select2.css"]} + +INTERNAL_IPS = ["localhost", "127.0.0.1"] + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "incremental": True, + "root": { + "level": "DEBUG", + }, +} + +CORS_ORIGIN_ALLOW_ALL = True + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", + "LOCATION": "127.0.0.1:11211", + #'OPTIONS': { + # 'server_max_value_length': 1024 * 1024 * 10 + # } + }, + "filesystem": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": "/var/tmp/django_cache", + }, +} + +THUMBNAIL_ALIASES = { + "": { + "exam-list": {"size": (50, 50), "crop": True}, + }, +} + + +DATA_UPLOAD_MAX_NUMBER_FIELDS = 50000 + +THUMBNAIL_SOURCE_GENERATORS = ( + "easy_thumbnails.source_generators.pil_image", + "helpers.images.pil_dicom_image", +) +DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage" +DBBACKUP_STORAGE_OPTIONS = {"location": os.path.join(BASE_DIR, "../backups")} + +DEFAULT_FILE_STORAGE = ( + "django_hashedfilenamestorage.storage.HashedFilenameFileSystemStorage" +) + +TINYMCE_DEFAULT_CONFIG = { + "theme": "silver", + "height": 500, + "menubar": False, + "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor," + "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste," + "code,help,wordcount", + "toolbar": "undo redo | formatselect | code" + "bold italic backcolor | alignleft aligncenter " + "alignright alignjustify | bullist numlist outdent indent | " + "removeformat | code | help", + "skin": "oxide-dark", + "menubar": "tools", + "content_css": "dark", +} + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + +EMAIL_HOST = "smtp.office365.com " +EMAIL_HOST_USER = "Penra.courses@nhs.net" +EMAIL_HOST_PASSWORD = '"got3<}%o"~\J",\Di3<' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_USE_SSl = False +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER + +try: + from .settings_local import * +except ImportError: + pass diff --git a/rapids/forms.py b/rapids/forms.py index b371107f..0f01c53e 100755 --- a/rapids/forms.py +++ b/rapids/forms.py @@ -1,241 +1,241 @@ -from django.forms import ( - Form, - ModelForm, - ModelMultipleChoiceField, - ModelChoiceField, - ChoiceField, - CharField, -) -from django.forms import inlineformset_factory - -from rapids.models import ( - Abnormality, - Examination, - Rapid, - RapidCreationDefault, - RapidImage, - Region, - Answer, - CidUserAnswer, - Exam, -) - -from django.contrib.admin.widgets import FilteredSelectMultiple -from django.forms.widgets import RadioSelect, TextInput, Textarea - - -class RapidAnswerForm(ModelForm): - class Meta: - model = CidUserAnswer - fields = ("answer",) - - -class MarkRapidQuestionForm(Form): - # correct = forms.CharField(required=False) - # half_correct = forms.CharField(required=False) - # incorrect = forms.CharField(required=False) - marked_answers = CharField(required=False) - - -class ExaminationForm(ModelForm): - class Meta: - model = Examination - fields = ["examination"] - - -class RegionForm(ModelForm): - class Meta: - model = Region - fields = ["name"] - - -class AbnormalityForm(ModelForm): - class Meta: - model = Abnormality - fields = ["name"] - - -class RapidCreationDefaultForm(ModelForm): - class Meta: - model = RapidCreationDefault - # fields = ["site"] - exclude = ["author"] - - -class RapidForm(ModelForm): - - # exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False)) - class Media: - # Django also includes a few javascript files necessary - # for the operation of this form element. You need to - # include - # in the template. - css = { - "all": ["css/widgets.css"], - } - # Adding this javascript is crucial - js = ["jsi18n.js", "tesseract.min.js"] - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop( - "user" - ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole - if kwargs.get("instance"): - # We get the 'initial' keyword argument or initialize it - # as a dict if it didn't exist. - initial = kwargs.setdefault("initial", {}) - # The widget for a ModelMultipleChoiceField expects - # a list of primary key for the selected data. - initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] - - ModelForm.__init__(self, *args, **kwargs) - super(RapidForm, self).__init__(*args, **kwargs) - # 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["abnormality"] = ModelMultipleChoiceField( - required=False, - queryset=Abnormality.objects.all(), - widget=FilteredSelectMultiple(verbose_name="Abnormality", is_stacked=False), - ) - - self.fields["region"] = ModelMultipleChoiceField( - required=False, - queryset=Region.objects.all(), - widget=FilteredSelectMultiple(verbose_name="Region", is_stacked=False), - ) - - self.fields["examination"] = ModelMultipleChoiceField( - queryset=Examination.objects.all(), - widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False), - ) - - self.fields["laterality"] = ChoiceField( - choices=Rapid.LATERALITY_CHOICES, required=False, widget=RadioSelect() - ) - - if self.user.groups.filter(name="rapid_checker").exists(): - exam_queryset = Exam.objects.all() - else: - exam_queryset = Exam.objects.filter( - author__id=self.user.id - ) | Exam.objects.filter(open_access=True) - - self.fields["exams"] = ModelMultipleChoiceField( - required=False, - queryset=exam_queryset, - widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), - ) - - def save(self, commit=True): - instance = ModelForm.save(self, False) - instance.save() - - old_exams = instance.exams.all() - - new_exams = self.cleaned_data["exams"] - - for exam in old_exams: - if exam not in new_exams: - exam.exam_questions.remove(instance) - - for exam in new_exams: - exam.exam_questions.add(instance) - #exam.save() - - # # Prepare a 'save_m2m' method for the form, - # old_save_m2m = self.save_m2m - # - # def save_m2m(): - # old_save_m2m() - # # This is where we actually link the pizza with toppings - # instance.exams.clear() - # for exam in self.cleaned_data["exams"]: - # instance.exams.add(exam) - # - # self.save_m2m = save_m2m - # - # # Do we need to save all changes now? - # # if commit: - # instance.save() - self.save_m2m() - - return instance - - class Meta: - model = Rapid - # fields = ['due_back'] - # fields = '__all__' - fields = [ - "normal", - "abnormality", - "region", - "laterality", - "examination", - # "site", - "feedback", - "open_access", - ] - # fields = ['question', 'feedback', 'subspecialty', 'references'] - widgets = { - # "normal": RadioSelect( - # choices=[(True, 'Yes'), - # (False, 'No')]) - } - - -ImageFormSet = inlineformset_factory( - Rapid, - RapidImage, - fields=["image", "feedback_image"], - exclude=[], - can_delete=True, - extra=1, - max_num=10, - field_classes="testing", -) - -AnswerFormSet = inlineformset_factory( - Rapid, - Answer, - fields=["answer", "status"], - widgets={ - "answer": Textarea( - attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3} - ) - }, - exclude=[], - can_delete=True, - extra=0, - max_num=10, -) - -AnswerUpdateFormSet = inlineformset_factory( - Rapid, - Answer, - fields=["answer", "status"], - widgets={ - "answer": Textarea( - attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3} - ) - }, - exclude=[], - can_delete=True, - extra=0, - max_num=10, -) - - -# This should be made generic? -class ExamForm(ModelForm): - class Meta: - model = Exam - fields = [ - "name", - "time_limit", - "open_access", - "exam_mode", - "active", - "publish_results", - "archive", - ] +from django.forms import ( + Form, + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, + CharField, +) +from django.forms import inlineformset_factory + +from rapids.models import ( + Abnormality, + Examination, + Rapid, + RapidCreationDefault, + RapidImage, + Region, + Answer, + CidUserAnswer, + Exam, +) + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms.widgets import RadioSelect, TextInput, Textarea + + +class RapidAnswerForm(ModelForm): + class Meta: + model = CidUserAnswer + fields = ("answer",) + + +class MarkRapidQuestionForm(Form): + # correct = forms.CharField(required=False) + # half_correct = forms.CharField(required=False) + # incorrect = forms.CharField(required=False) + marked_answers = CharField(required=False) + + +class ExaminationForm(ModelForm): + class Meta: + model = Examination + fields = ["examination"] + + +class RegionForm(ModelForm): + class Meta: + model = Region + fields = ["name"] + + +class AbnormalityForm(ModelForm): + class Meta: + model = Abnormality + fields = ["name"] + + +class RapidCreationDefaultForm(ModelForm): + class Meta: + model = RapidCreationDefault + # fields = ["site"] + exclude = ["author"] + + +class RapidForm(ModelForm): + + # exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False)) + class Media: + # Django also includes a few javascript files necessary + # for the operation of this form element. You need to + # include + # in the template. + css = { + "all": ["css/widgets.css"], + } + # Adding this javascript is crucial + js = ["jsi18n.js", "tesseract.min.js"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop( + "user" + ) # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] + + ModelForm.__init__(self, *args, **kwargs) + super(RapidForm, self).__init__(*args, **kwargs) + # 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["abnormality"] = ModelMultipleChoiceField( + required=False, + queryset=Abnormality.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Abnormality", is_stacked=False), + ) + + self.fields["region"] = ModelMultipleChoiceField( + required=False, + queryset=Region.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Region", is_stacked=False), + ) + + self.fields["examination"] = ModelMultipleChoiceField( + queryset=Examination.objects.all(), + widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False), + ) + + self.fields["laterality"] = ChoiceField( + choices=Rapid.LATERALITY_CHOICES, required=False, widget=RadioSelect() + ) + + if self.user.groups.filter(name="rapid_checker").exists(): + exam_queryset = Exam.objects.all() + else: + exam_queryset = Exam.objects.filter( + author__id=self.user.id + ) | Exam.objects.filter(open_access=True) + + self.fields["exams"] = ModelMultipleChoiceField( + required=False, + queryset=exam_queryset, + widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False), + ) + + def save(self, commit=True): + instance = ModelForm.save(self, False) + instance.save() + + old_exams = instance.exams.all() + + new_exams = self.cleaned_data["exams"] + + for exam in old_exams: + if exam not in new_exams: + exam.exam_questions.remove(instance) + + for exam in new_exams: + exam.exam_questions.add(instance) + #exam.save() + + # # Prepare a 'save_m2m' method for the form, + # old_save_m2m = self.save_m2m + # + # def save_m2m(): + # old_save_m2m() + # # This is where we actually link the pizza with toppings + # instance.exams.clear() + # for exam in self.cleaned_data["exams"]: + # instance.exams.add(exam) + # + # self.save_m2m = save_m2m + # + # # Do we need to save all changes now? + # # if commit: + # instance.save() + self.save_m2m() + + return instance + + class Meta: + model = Rapid + # fields = ['due_back'] + # fields = '__all__' + fields = [ + "normal", + "abnormality", + "region", + "laterality", + "examination", + # "site", + "feedback", + "open_access", + ] + # fields = ['question', 'feedback', 'subspecialty', 'references'] + widgets = { + # "normal": RadioSelect( + # choices=[(True, 'Yes'), + # (False, 'No')]) + } + + +ImageFormSet = inlineformset_factory( + Rapid, + RapidImage, + fields=["image", "feedback_image"], + exclude=[], + can_delete=True, + extra=1, + max_num=10, + field_classes="testing", +) + +AnswerFormSet = inlineformset_factory( + Rapid, + Answer, + fields=["answer", "status"], + widgets={ + "answer": Textarea( + attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3} + ) + }, + exclude=[], + can_delete=True, + extra=0, + max_num=10, +) + +AnswerUpdateFormSet = inlineformset_factory( + Rapid, + Answer, + fields=["answer", "status"], + widgets={ + "answer": Textarea( + attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3} + ) + }, + exclude=[], + can_delete=True, + extra=0, + max_num=10, +) + + +# This should be made generic? +class ExamForm(ModelForm): + class Meta: + model = Exam + fields = [ + "name", + "time_limit", + "open_access", + "exam_mode", + "active", + "publish_results", + "archive", + ] diff --git a/rapids/templates/rapids/mark_overview.html b/rapids/templates/rapids/mark_overview.html index f958774c..f363318b 100644 --- a/rapids/templates/rapids/mark_overview.html +++ b/rapids/templates/rapids/mark_overview.html @@ -1,20 +1,20 @@ -{% extends 'rapids/exams.html' %} - -{% block content %} -
-

Marking exam: {{ exam.name }}

- You can start marking from a particular question by clicking on it below. - -
- -
- - - -
+{% extends 'rapids/exams.html' %} + +{% block content %} +
+

Marking exam: {{ exam.name }}

+ You can start marking from a particular question by clicking on it below. + +
+ +
+ + + +
{% endblock %} \ No newline at end of file diff --git a/rapids/views.py b/rapids/views.py index 81380a92..aa01fcf2 100755 --- a/rapids/views.py +++ b/rapids/views.py @@ -1,1290 +1,1290 @@ -import json -from django.shortcuts import render, get_object_or_404, redirect -from django import forms - -# from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required, user_passes_test -from django.contrib.auth.models import User -from django.core.exceptions import PermissionDenied - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic.detail import DetailView - -from django.views.generic.edit import CreateView, UpdateView, DeleteView -from django.views.generic import ListView -from django.views.decorators.csrf import csrf_exempt - -from django.urls import reverse_lazy, reverse - -from django.http import Http404, JsonResponse -from django.http import HttpResponseRedirect, HttpResponse - -from .forms import ( - RapidForm, - MarkRapidQuestionForm, - ImageFormSet, - RegionForm, - AbnormalityForm, - ExaminationForm, - AnswerFormSet, - AnswerUpdateFormSet, - ExamForm, -) -from .models import ( - Rapid, - Abnormality, - RapidImage, - Region, - Examination, - Exam, - Answer, - CidUserAnswer, -) - -from generic.views import ExamCloneMixin, ExamViews, GenericViewBase - -from reversion.views import RevisionMixin - -from .tables import RapidTable, RapidUserAnswerTable -from .filters import RapidFilter, RapidUserAnswerFilter - -from django_tables2 import SingleTableView, SingleTableMixin -from django_filters.views import FilterView - -from .decorators import user_is_author_or_rapid_checker - -from collections import defaultdict -import json -import statistics -import plotly.express as px - -from django.core.cache import cache - -from helpers.images import image_as_base64 - -import logging -from copy import deepcopy -from django.forms.models import model_to_dict -from rapids.forms import RapidCreationDefaultForm -from rapids.models import RapidCreationDefault - - -from rest_framework import viewsets, permissions, mixins -from rest_framework.pagination import PageNumberPagination - -from .serializers import ( - ExamSerializer, - QuestionAnswerSerializer, - RapidLateralitySerializer, - RapidSerializer, - UserAnswerSerializer, -) - -from generic.mixins import SuperuserRequiredMixin - -logger = logging.getLogger(__name__) - - -def normaliseScore(score): - if score == 49: - return 4.5 - elif score in [50, 51]: - return 5 - elif score in [52, 53]: - return 5.5 - elif score in [54]: - return 6 - elif score in [55, 56]: - return 6.5 - elif score in [57, 58]: - return 7 - elif score in [59]: - return 7.5 - elif score in [60]: - return 8 - - return 4 - - -class AuthorOrCheckerRequiredMixin(object): - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user.groups.filter(name="rapid_checker").exists(): - return obj - if self.request.user not in obj.author.all(): - raise PermissionDenied() # or Http404 - return obj - - -@login_required -def rapid_split(request, pk): - rapid = get_object_or_404(Rapid, pk=pk) - - if ( - not request.user.groups.filter(name="rapid_checker").exists() - and request.user not in rapid.author.all() - ): - raise PermissionDenied() - - images = rapid.images.all() - - old_abnormality = rapid.abnormality.all() - old_region = rapid.region.all() - old_examination = rapid.examination.all() - # old_site = rapid.site.all() - old_author = rapid.author.all() - - if not images: - raise Http404 - - for n in range(len(images) - 1): - rapid.pk = None - - rapid.save() - - rapid.abnormality.set(old_abnormality) - rapid.region.set(old_region) - rapid.examination.set(old_examination) - # rapid.site.set(old_site) - rapid.author.set(old_author) - images[n].rapid = rapid - images[n].save() - - # images[-1].rapid - - # if request.user not in rapid.author.all(): - # raise PermissionDenied - - # logging.debug(rapid.subspecialty.first().name.all()) - return render(request, "rapids/question_detail.html", {"question": rapid}) - - -class RapidCreationDefaultView(LoginRequiredMixin, UpdateView): - model = RapidCreationDefault - form_class = RapidCreationDefaultForm - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - # def dispatch(self, request, *args, **kwargs): - # """ - # Overridden so we can make sure the `Rapid` instance exists - # before going any further. - # """ - # self.pk = get_object_or_404(Rapid, pk=kwargs['pk']) - # return super().dispatch(request, *args, **kwargs) - def get_object(self, queryset=None): - obj, create = RapidCreationDefault.objects.get_or_create( - author=self.request.user - ) - - return obj - - def form_valid(self, form): - - model = form.save(commit=False) - - model.author = self.request.user - model.save() - - response = super().form_valid(form) - - return response - - # form.instance.author.add(self.request.user.id) - - -@login_required -def rapid_clone(request, pk): - new_item = get_object_or_404(Rapid, pk=pk) - new_item.pk = None # autogen a new pk (item_id) - # new_item.name = "Copy of " + new_item.name #need to change uniques - - form = RapidForm(request.POST or None, instance=new_item) - - image_formset = ImageFormSet() - answer_formset = AnswerFormSet() - - if form.is_valid(): - form.instance.author.add(request.user.id) - - # logger.debug(formset.is_valid()) - if image_formset.is_valid() and answer_formset.is_valid(): - response = super().form_valid(form) - image_formset.instance = obj - image_formset.save() - - answer_formset.instance = obj - answer_formset.save() - return response - else: - return super().form_invalid(form) - - context = { - "form": form, - "image_formset": image_formset, - "answer_formset": answer_formset - # other context - } - - return render(request, "rapids/rapid_form.html", context) - - -class RapidCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): - model = Rapid - form_class = RapidForm - - # Sending user object to the form, to verify which fields to display/remove (depending on group) - def get_form_kwargs(self): - kwargs = super(RapidCreateBase, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(RapidCreateBase, self).get_context_data(**kwargs) - if self.request.POST: - context["image_formset"] = ImageFormSet( - self.request.POST, self.request.FILES - ) - context["image_formset"].full_clean() - context["answer_formset"] = AnswerFormSet(self.request.POST) - context["answer_formset"].full_clean() - else: - context["image_formset"] = ImageFormSet() - context["answer_formset"] = AnswerFormSet() - return context - - def form_valid(self, form): - - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - image_formset = context["image_formset"] - answer_formset = context["answer_formset"] - if image_formset.is_valid() and answer_formset.is_valid(): - response = super().form_valid(form) - image_formset.instance = self.object - image_formset.save() - answer_formset.instance = self.object - answer_formset.save() - # If the normal submit button is pressed we save as normal - if "submit" in self.request.POST: - return response - # else we redirect to the clone url - else: - return redirect("rapids:rapid_clone", pk=self.object.pk) - - else: - return super().form_invalid(form) - - -# @login_required -class RapidCreate(RapidCreateBase): - - # initial = {"laterality": Rapid.NONE} - - def get_initial(self): - if "pk" in self.kwargs: - initial = super().get_initial() - exam = get_object_or_404(Exam, pk=self.kwargs["pk"]) - - initial["exams"] = [exam.id] - - return initial - - ## There has to be a better way... - # try: - # s = (i.pk for i in self.request.user.rapid_default.site.all()) - # self.initial.update({"site": s}) - # except AttributeError: - # pass - # return self.initial - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - - # self.object = form.save(commit=False) - # self.object.save() - - # form.instance.author.add(self.request.user.id) - # return super().form_valid(form) - - -class RapidUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Rapid - form_class = RapidForm - - # fields = '__all__' - # #fields = [ 'condition' ] - # #initial = {'date_of_death': '05/01/2018'} - # exclude = [ 'created_date', 'published_date' ] - def get_form_kwargs(self): - kwargs = super(RapidUpdate, self).get_form_kwargs() - kwargs.update({"user": self.request.user}) - return kwargs - - def get_context_data(self, **kwargs): - context = super(RapidUpdate, self).get_context_data(**kwargs) - if self.request.POST: - context["image_formset"] = ImageFormSet( - self.request.POST, self.request.FILES, instance=self.object - ) - context["image_formset"].full_clean() - context["answer_formset"] = AnswerFormSet( - self.request.POST, instance=self.object - ) - context["answer_formset"].full_clean() - else: - context["image_formset"] = ImageFormSet(instance=self.object) - context["answer_formset"] = AnswerFormSet(instance=self.object) - return context - - def form_valid(self, form): - # save exam orders (there must be a better way to do this) - #exam_orders = {} - #for exam in self.object.exams.all(): - # exam_orders[exam] = list(exam.exam_questions.all()) - - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - - context = self.get_context_data(form=form) - image_formset = context["image_formset"] - answer_formset = context["answer_formset"] - # logger.debug(formset.is_valid()) - if image_formset.is_valid() and answer_formset.is_valid(): - response = super().form_valid(form) - image_formset.instance = self.object - image_formset.save() - answer_formset.instance = self.object - answer_formset.save() - - # restore exam orders - #wanted_exams = form["exams"].data - #for exam in self.object.exams.all(): - # if exam in exam_orders and self.object in exam_orders[exam]: - # print(exam_orders[exam]) - # exam.exam_questions.set(exam_orders[exam]) - # exam.save() - - return response - else: - return super().form_invalid(form) - - -class RapidClone(RapidCreateBase): - """Clones a existing rapid""" - - def get_initial(self): - # print(self.request) - old_object = get_object_or_404(Rapid, pk=self.kwargs["pk"]) - initial_data = model_to_dict(old_object, exclude=["id"]) - - return initial_data - - -@login_required -@user_is_author_or_rapid_checker -def rapid_scrap(request, pk): - try: - rapid = Rapid.objects.get(pk=pk) - except Rapid.DoesNotExist: - raise Http404("Rapid does not exist") - - if ( - not request.user.groups.filter(name="rapid_checker").exists() - and request.user not in rapid.author.all() - ): - raise PermissionDenied() - - rapid.scrapped = not rapid.scrapped - rapid.save() - return HttpResponseRedirect(reverse("rapids:question_detail", args=(pk,))) - - -# @login_required -# def edit_abnormality_popup(request): -# instance = get_object_or_404(Abnormality, pk = pk) -# form = AbnormalityForm(request.POST or None) -# if form.is_valid(): -# instance = form.save() -# return HttpResponse('' % (instance.pk, instance)) -# return render(request, "rapids/create_simple.html", {'form': form}) - - -@login_required -def create_abnormality(request): - form = AbnormalityForm(request.POST or None) - if form.is_valid(): - instance = form.save() - return HttpResponse( - '' - % (instance.pk, instance) - ) - return render( - request, "rapids/create_simple.html", {"form": form, "name": "Abnormality"} - ) - - -@csrf_exempt -def get_abnormality_id(request): - if request.is_ajax(): - abnormality_name = request.GET["abnormality_name"] - abnormality_id = Abnormality.objects.get(name=abnormality_name).id - data = { - "abnormality_id": abnormality_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -@login_required -def create_examination(request): - form = ExaminationForm(request.POST or None) - if form.is_valid(): - instance = form.save() - return HttpResponse( - '' - % (instance.pk, instance) - ) - return render( - request, "rapids/create_simple.html", {"form": form, "name": "Examination"} - ) - - -@csrf_exempt -def get_examination_id(request): - if request.is_ajax(): - examination_name = request.GET["examination_name"] - examination_id = Examination.objects.get(name=examination_name).id - data = { - "examination_id": examination_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -@login_required -def create_region(request): - form = RegionForm(request.POST or None) - if form.is_valid(): - instance = form.save() - return HttpResponse( - '' - % (instance.pk, instance) - ) - return render( - request, "rapids/create_simple.html", {"form": form, "name": "Region"} - ) - - -@csrf_exempt -def get_region_id(request): - if request.is_ajax(): - region_name = request.GET["region_name"] - region_id = Region.objects.get(name=region_name).id - data = { - "region_id": region_id, - } - return HttpResponse(json.dumps(data), content_type="application/json") - return HttpResponse("/") - - -class RapidView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Rapid - table_class = RapidTable - template_name = "rapids/view.html" - - filterset_class = RapidFilter - - -def loadJsonAnswer(answer): - eid = int(answer["eid"].split("/")[1]) - cid = int(answer["cid"]) - - # As access is not restricted make sure the data appears valid - if (not isinstance(cid, int)) or ( - not isinstance(eid, int) or (not isinstance(answer["ans"], str)) - ): - return False, JsonResponse( - {"success": False, "error": "cid or eid or answers not defined"} - ) - - # The model should catch invalid data but this should be less intensive - max_int = 999999999999999999999 - if cid > max_int or eid > max_int: - return False, JsonResponse({"success": False, "error": "invalid cid"}) - - exam = get_object_or_404(Exam, pk=eid) - - if not exam.check_cid_user(cid, answer["passcode"]): - return False, JsonResponse( - {"success": False, "error": "invalid cid / passcode"} - ) - - # if not exam.active: - # return False, JsonResponse( - # {"success": False, "error": "No active exam: {}".format(eid)} - # ) - - exiting_answers = CidUserAnswer.objects.filter( - question__id=answer["qid"], exam__id=eid, cid=cid - ) - - posted_answer = answer["ans"] - - ## qidn 1 does not hold answer data (but should always arrive prior to 2) - # if answer["qidn"] == "1": - # posted_answer = "" - - # Normal answers are just posted with the answer "Normal" - normal = False - if posted_answer == "Normal": - normal = True - - if not exiting_answers: - ans = CidUserAnswer(answer=posted_answer, normal=normal, cid=cid) - ans.question_id = answer["qid"] - ans.exam_id = eid - - ans.full_clean() - - ans.save() - else: - # Update an existing answer - # should never be more than one (famous last words) - ans = exiting_answers[0] - if answer["qidn"] == "1": - ans.normal = normal - ans.answer = "" - elif answer["qidn"] == "2" and not ans.normal: - ans.answer = posted_answer - ans.full_clean() - ans.save() - - return True, None - - -@login_required -def mark_all(request, exam_pk, sk): - return mark(request, exam_pk, sk, unmarked_exam_answers_only=False) - - -@login_required -def mark_review(request, exam_pk, sk): - return mark(request, exam_pk, sk, unmarked_exam_answers_only=False, review=True) - - -@login_required -def exam_scores_cid2(request, pk): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - user_answers_and_marks = defaultdict(list) - user_answers_marks = defaultdict(list) - user_answers = defaultdict(list) - user_names = {} - - score_by_question = defaultdict(dict) - ans_by_question = defaultdict(dict) - unmarked = set() - - questions = exam.exam_questions.all() - - cid_user_answers = CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) - - cids = set() - - # Loop through all candidates - for cid_user_answer in cid_user_answers: - # Convoluted (probably...) - cid = cid_user_answer.cid - cids.add(cid) - s = cid_user_answer - user_names[cid] = cid - - q = cid_user_answer.question - - # if not s: - # # skip if no answer - # user_answers_marks[cid].append(0) - # user_answers[cid].append("") - # by_question[q].append(("", 0)) - # continue - if s.normal: - ans = "Normal" - else: - ans = s.answer - answer_score = s.get_answer_score() - if answer_score == "unmarked": - index = exam.get_question_index(q) - unmarked.add(index) - user_answers[cid].append(ans) - user_answers_marks[cid].append(answer_score) - user_answers_and_marks[cid].append((ans, answer_score)) - - score_by_question[q][cid] = answer_score - ans_by_question[q][cid] = ans - - user_scores = {} - user_scores_normalised = {} - for user in user_answers_marks: - user_scores[user] = sum( - [i for i in user_answers_marks[user] if i != "unmarked"] - ) - user_scores_normalised[user] = normaliseScore( - sum([i for i in user_answers_marks[user] if i != "unmarked"]) - ) - - user_scores_list = list(user_scores.values()) - - if len(user_scores_list) < 1: - mean = 0 - median = 0 - mode = 0 - fig_html = "" - else: - mean = statistics.mean(user_scores_list) - median = statistics.median(user_scores_list) - try: - mode = statistics.mode(user_scores_list) - except statistics.StatisticsError: - mode = "No unique mode" - - df = user_scores_list - fig = px.histogram( - df, - x=0, - title="{}: distribution of scores".format(exam), - labels={"0": "Score"}, - height=400, - width=600, - ) - fig_html = fig.to_html() - - max_score = len(questions) * 2 - - return render( - request, - "rapids/exam_scores_new.html", - { - "cids": sorted(cids), - "exam": exam, - "unmarked": unmarked, - "questions": questions, - "score_by_question": score_by_question, - "ans_by_question": ans_by_question, - "user_answers": dict(user_answers), - "user_answers_marks": dict(user_answers_marks), - "user_scores": user_scores, - "user_scores_normalised": user_scores_normalised, - "user_scores_list": user_scores_list, - "user_names": user_names, - "user_answers_and_marks": user_answers_and_marks, - "max_score": max_score, - "mean": mean, - "median": median, - "mode": mode, - "plot": fig_html, - }, - ) - - -# @user_passes_test(user_is_admin, login_url="/accounts/login") -@login_required -def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): - exam = get_object_or_404(Exam, pk=exam_pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - mark_url = "rapids:mark" - if review: - mark_url = "rapids:mark_review" - elif not unmarked_exam_answers_only: - mark_url = "rapids:mark_all" - - questions = exam.exam_questions.all() - - n = sk - - question_details = { - "total": len(questions), - "current": n + 1, - } - - try: - question = questions[sk] - except IndexError: - raise Http404("Exam question does not exist") - - if request.method == "POST": - - form = MarkRapidQuestionForm(request.POST) - # ******************************* - # TODO: convert to JSON request - # ******************************* - if form.is_valid(): - # If skip button is pressed skip the question without marking - if "skip" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) - - cd = form.cleaned_data - - marked_answers = json.loads(cd.get("marked_answers")) - - # This should probably use json (or something else...) - # ajax..... - to_run = ( - ("correct", Answer.MarkOptions.CORRECT), - ("half-correct", Answer.MarkOptions.HALF_MARK), - ("incorrect", Answer.MarkOptions.INCORRECT), - ) - - for status_string, status in to_run: - for ans in marked_answers[status_string]: - ans = ans.strip() - if ans == "": - continue - - a = Answer.objects.filter( - answer__iexact=ans, question_id=question.pk - ).first() - - if a is None: - a = Answer() - a.question_id = question.pk - a.answer = ans - - a.status = status - a.save() - - if "next" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) - elif "previous" in request.POST: - return redirect(mark_url, exam_pk=exam_pk, sk=n - 1) - - # Reset user answers (relies on the functional javascript) - unmarked_user_answers = set() - else: - form = MarkRapidQuestionForm() - - # answers_dict = {} - - # for ans in question.answers.all(): - # answers_dict[ans.answer_compare] = ans - - correct_answers = [] - half_mark_answers = [] - incorrect_answers = [] - review_user_answers = [] - unmarked_answers_bool = False - unmarked_user_answers = [] - # this could be improved - if question.normal: - incorrect_answers = question.get_user_answers(exam_pk=exam_pk) - if "" in incorrect_answers: - incorrect_answers.remove("") - if "normal" in incorrect_answers: - incorrect_answers.remove("normal") - else: - if review: - unmarked_user_answers = question.get_user_answers( - exam_pk=exam_pk, include_normal=False - ) - elif unmarked_exam_answers_only: - unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk) - else: - unmarked_user_answers = question.get_unmarked_user_answers() - - if not unmarked_exam_answers_only: - correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT) - half_mark_answers = question.answers.filter( - status=Answer.MarkOptions.HALF_MARK - ) - incorrect_answers = question.answers.filter( - status=Answer.MarkOptions.INCORRECT - ) - - if review: - for ans in unmarked_user_answers: - # duplicated from user answer model.... - marked_ans = question.answers.filter(answer_compare__iexact=ans).first() - mark = "unmarked" - if marked_ans is not None: - if marked_ans.status == Answer.MarkOptions.CORRECT: - mark = "correct" - elif marked_ans.status == Answer.MarkOptions.HALF_MARK: - mark = "half-correct" - elif marked_ans.status == Answer.MarkOptions.INCORRECT: - mark = "incorrect" - if mark == "unmarked": - unmarked_answers_bool = True - review_user_answers.append((ans, mark)) - - return render( - request, - "rapids/mark.html", - { - "exam": exam, - "form": form, - "review": review, - "question": question, - "question_number": n, - "question_details": question_details, - "unmarked_answers_bool": unmarked_answers_bool, - "user_answers": unmarked_user_answers, - "review_user_answers": review_user_answers, - "correct_answers": correct_answers, - "half_mark_answers": half_mark_answers, - "incorrect_answers": incorrect_answers, - "unmarked_exam_answers_only": unmarked_exam_answers_only, - }, - ) - - -@login_required -def exam_scores_cid(request, pk): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - questions = exam.exam_questions.all() - - cids = ( - CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) - .order_by("cid") - .values_list("cid", flat=True) - .distinct() - ) - - user_answers_and_marks = defaultdict(list) - user_answers_marks = defaultdict(list) - user_answers = defaultdict(list) - user_names = {} - - by_question = defaultdict(list) - unmarked = set() - - # Loop through all candidates - for cid in cids: - # Convoluted (probably...) - user_names[cid] = cid - for q in questions: - # Get user answer - s = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() - - if not s: - # skip if no answer - user_answers_marks[cid].append(0) - user_answers[cid].append("") - by_question[q].append(("", 0)) - continue - elif s.normal: - ans = "Normal" - else: - ans = s.answer - answer_score = s.get_answer_score() - if answer_score == "unmarked": - index = exam.get_question_index(q) - unmarked.add(index) - user_answers[cid].append(ans) - user_answers_marks[cid].append(answer_score) - user_answers_and_marks[cid].append((ans, answer_score)) - - by_question[q].append((ans, answer_score)) - - user_scores = {} - user_scores_normalised = {} - for user in user_answers_marks: - user_scores[user] = sum( - [i for i in user_answers_marks[user] if i != "unmarked"] - ) - user_scores_normalised[user] = normaliseScore( - sum([i for i in user_answers_marks[user] if i != "unmarked"]) - ) - - user_scores_list = list(user_scores.values()) - - if len(user_scores_list) < 1: - mean = 0 - median = 0 - mode = 0 - fig_html = "" - else: - mean = statistics.mean(user_scores_list) - median = statistics.median(user_scores_list) - try: - mode = statistics.mode(user_scores_list) - except statistics.StatisticsError: - mode = "No unique mode" - - df = user_scores_list - fig = px.histogram( - df, - x=0, - title="{}: distribution of scores".format(exam), - labels={"0": "Score"}, - height=400, - width=600, - ) - fig_html = fig.to_html() - - max_score = len(questions) * 2 - - return render( - request, - "rapids/exam_scores.html", - { - "cids": cids, - "exam": exam, - "unmarked": unmarked, - "questions": questions, - "by_question": by_question, - "user_answers": dict(user_answers), - "user_answers_marks": dict(user_answers_marks), - "user_scores": user_scores, - "user_scores_normalised": user_scores_normalised, - "user_scores_list": user_scores_list, - "user_names": user_names, - "user_answers_and_marks": user_answers_and_marks, - "max_score": max_score, - "mean": mean, - "median": median, - "mode": mode, - "plot": fig_html, - }, - ) - - -def exam_scores_cid_user(request, pk, cid, passcode): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.exam_mode: - raise Http404("Packet not in exam mode") - - # TODO:Need some kind of test for cid - - questions = exam.exam_questions.all() - - answers_and_marks = [] - answers_marks = [] - answers = [] - - view_all_results = False - if request.user.groups.filter(name="view_all_results").exists(): - view_all_results = True - - for q in questions: - # Get user answer - user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() - - if not user_answer or user_answer is None: - # skip if no answer - answers_marks.append(0) - # answers.append("") - answer_score = 0 - ans = "Not answered" - else: - if user_answer.normal: - ans = "Normal" - else: - ans = user_answer.answer - answer_score = user_answer.get_answer_score() - - correct_answer = q.get_primary_answer() - - if not exam.publish_results and not view_all_results: - correct_answer = "*****" - answer_score = 0 - answers.append(ans) - answers_marks.append(answer_score) - answers_and_marks.append((ans, answer_score, correct_answer)) - - if "unmarked" in answers_marks: - 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) - else: - total_score = sum(answers_marks) - - normalised_score = normaliseScore(total_score) - - max_score = len(questions) * 2 - - return render( - request, - "rapids/exam_scores_user.html", - { - "exam": exam, - "cid": cid, - "passcode": passcode, - "questions": questions, - "answers": answers, - "answers_marks": answers_marks, - "total_score": total_score, - "normalised_score": normalised_score, - "max_score": max_score, - "answers_and_marks": answers_and_marks, - "view_all_results": view_all_results, - }, - ) - - -class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): - model = Rapid - success_url = reverse_lazy("rapids:rapid_view") - - -GenericExamViews = ExamViews( - Exam, Rapid, Answer, CidUserAnswer, "rapids", "rapid", loadJsonAnswer -) - - -class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - return super().form_valid(form) - - -class ExamUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - return super().form_valid(form) - - -class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Exam - template_name = "exam_confirm_delete.html" - success_url = reverse_lazy("rapids:index") - - -class UserAnswerView(LoginRequiredMixin, DetailView): - model = CidUserAnswer - - -class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = CidUserAnswer - table_class = RapidUserAnswerTable - template_name = "rapids/user_answer_question_view.html" - - filterset_class = RapidUserAnswerFilter - - -class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): - model = CidUserAnswer - template_name = "user_answer_delete.html" - success_url = reverse_lazy("rapids:user_answer_table_view") - - -class LargeResultsSetPagination(PageNumberPagination): - page_size = 1000 - page_size_query_param = "page_size" - max_page_size = 10000 - - -class StandardResultsSetPagination(PageNumberPagination): - page_size = 100 - page_size_query_param = "page_size" - max_page_size = 1000 - - -class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): - # queryset = Exam.objects.all().order_by('name') - serializer_class = ExamSerializer - - def get_queryset(self): - """ - This view should return a list exams available to a user. - """ - user = self.request.user - if user.groups.filter(name="rapid_checker").exists(): - return Exam.objects.all() - - return Exam.objects.filter(author__id=user.id) - - -class QuestionAnswerViewSet(viewsets.ModelViewSet): - queryset = Answer.objects.all() # .order_by('name') - serializer_class = QuestionAnswerSerializer - permission_classes = [permissions.IsAuthenticated] - - -class UserAnswerViewSet(viewsets.ModelViewSet): - queryset = CidUserAnswer.objects.all() # .order_by('name') - serializer_class = UserAnswerSerializer - permission_classes = [permissions.IsAuthenticated] - - -class RapidViewSet( - RevisionMixin, - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.ListModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet, -): - queryset = Rapid.objects.all() # .order_by('name') - serializer_class = RapidSerializer - permission_classes = [permissions.IsAuthenticated] - pagination_class = StandardResultsSetPagination - - -class RapidLateralityViewSet( - RevisionMixin, - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet, -): - queryset = Rapid.objects.all() # .order_by('name') - serializer_class = RapidLateralitySerializer - permission_classes = [permissions.IsAuthenticated] - pagination_class = StandardResultsSetPagination - - -# class AddSuggestedAnswer(LoginRequiredMixin, CreateView): -# model = QuestionNote -# form_class = QuestionNoteForm -# -# #fields = ("author", "note_type", "note") -# -# -# def get_form_kwargs(self): -# kwargs = super(AddQuestionNote, self).get_form_kwargs() -# # update the kwargs for the form init method with yours -# kwargs.update(self.kwargs) # self.kwargs contains all url conf params -# return kwargs -# -# def get_initial(self): -# pk = self.kwargs["pk"] -# question_type = self.kwargs["question_type"] -# if question_type == "rapid": -# question = Rapid -# content_type = ContentType.objects.get(model="rapid") -# elif question_type == "anatomy": -# question = AnatomyQuestion -# content_type = ContentType.objects.get(model="anatomyquestion") -# elif question_type == "long": -# question = Long -# content_type = ContentType.objects.get(model="long") -# else: -# raise PermissionError() -# -# get_object_or_404(question, pk=pk) -# return { "content_type" : content_type, -# "object_id" : pk } - -GenericViews = GenericViewBase("rapids", Rapid, CidUserAnswer, Exam) - - -@user_is_author_or_rapid_checker -def question_save_annotation(request, pk): - if request.is_ajax() and request.method == "POST": - question = get_object_or_404(Rapid, pk=pk) - - image_annotations = json.loads(request.POST.get("annotation")) - - question_images = question.images.filter(feedback_image=False) - - for n, image in enumerate(question_images): - annotation = image_annotations[n] - if annotation != "undefined": - image.image_annotations = annotation - else: - image.image_annotations = "" - image.save() - - data = {"status": "success"} - return JsonResponse(data, status=200) - else: - data = {"status": "error"} - return JsonResponse(data, status=400) - - -class ExamClone(ExamCloneMixin, ExamCreate): - """Clone exam view""" - - -@login_required -def get_question_by_hash(request): - if request.is_ajax() and request.method == "POST": - - hash = request.POST.get("hash") - - try: - question = RapidImage.objects.get(image_md5_hash=hash) - data = { - "status": "success", - "id": question.pk, - "url": question.rapid.get_absolute_url(), - } - return JsonResponse(data, status=200) - except RapidImage.DoesNotExist: - data = {"status": "success", "id": False} - return JsonResponse(data, status=200) - - -# TODO: better permissions -@login_required -def question_json_unbased(request, pk): - """ - No (file based) caching is enabled for unbased quesitons - """ - question = get_object_or_404(Rapid, pk=pk) - - question_json = question.get_question_json(based=False) - return JsonResponse(question_json) - - -@login_required -def question_json(request, pk): - question = get_object_or_404(Rapid, pk=pk) - - question_json = question.get_question_json(based=True) - return JsonResponse(question_json) - - -@login_required -def question_viewer(request): - # question = get_object_or_404(Rapid, pk=pk) - - ids = request.GET.get("ids").split(",") - - return render( - request, - "rapids/question_viewer.html", - { - "ids": ids, - }, - ) +import json +from django.shortcuts import render, get_object_or_404, redirect +from django import forms + +# from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic.detail import DetailView + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView +from django.views.decorators.csrf import csrf_exempt + +from django.urls import reverse_lazy, reverse + +from django.http import Http404, JsonResponse +from django.http import HttpResponseRedirect, HttpResponse + +from .forms import ( + RapidForm, + MarkRapidQuestionForm, + ImageFormSet, + RegionForm, + AbnormalityForm, + ExaminationForm, + AnswerFormSet, + AnswerUpdateFormSet, + ExamForm, +) +from .models import ( + Rapid, + Abnormality, + RapidImage, + Region, + Examination, + Exam, + Answer, + CidUserAnswer, +) + +from generic.views import ExamCloneMixin, ExamViews, GenericViewBase + +from reversion.views import RevisionMixin + +from .tables import RapidTable, RapidUserAnswerTable +from .filters import RapidFilter, RapidUserAnswerFilter + +from django_tables2 import SingleTableView, SingleTableMixin +from django_filters.views import FilterView + +from .decorators import user_is_author_or_rapid_checker + +from collections import defaultdict +import json +import statistics +import plotly.express as px + +from django.core.cache import cache + +from helpers.images import image_as_base64 + +import logging +from copy import deepcopy +from django.forms.models import model_to_dict +from rapids.forms import RapidCreationDefaultForm +from rapids.models import RapidCreationDefault + + +from rest_framework import viewsets, permissions, mixins +from rest_framework.pagination import PageNumberPagination + +from .serializers import ( + ExamSerializer, + QuestionAnswerSerializer, + RapidLateralitySerializer, + RapidSerializer, + UserAnswerSerializer, +) + +from generic.mixins import SuperuserRequiredMixin + +logger = logging.getLogger(__name__) + + +def normaliseScore(score): + if score == 49: + return 4.5 + elif score in [50, 51]: + return 5 + elif score in [52, 53]: + return 5.5 + elif score in [54]: + return 6 + elif score in [55, 56]: + return 6.5 + elif score in [57, 58]: + return 7 + elif score in [59]: + return 7.5 + elif score in [60]: + return 8 + + return 4 + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user.groups.filter(name="rapid_checker").exists(): + return obj + if self.request.user not in obj.author.all(): + raise PermissionDenied() # or Http404 + return obj + + +@login_required +def rapid_split(request, pk): + rapid = get_object_or_404(Rapid, pk=pk) + + if ( + not request.user.groups.filter(name="rapid_checker").exists() + and request.user not in rapid.author.all() + ): + raise PermissionDenied() + + images = rapid.images.all() + + old_abnormality = rapid.abnormality.all() + old_region = rapid.region.all() + old_examination = rapid.examination.all() + # old_site = rapid.site.all() + old_author = rapid.author.all() + + if not images: + raise Http404 + + for n in range(len(images) - 1): + rapid.pk = None + + rapid.save() + + rapid.abnormality.set(old_abnormality) + rapid.region.set(old_region) + rapid.examination.set(old_examination) + # rapid.site.set(old_site) + rapid.author.set(old_author) + images[n].rapid = rapid + images[n].save() + + # images[-1].rapid + + # if request.user not in rapid.author.all(): + # raise PermissionDenied + + # logging.debug(rapid.subspecialty.first().name.all()) + return render(request, "rapids/question_detail.html", {"question": rapid}) + + +class RapidCreationDefaultView(LoginRequiredMixin, UpdateView): + model = RapidCreationDefault + form_class = RapidCreationDefaultForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + # def dispatch(self, request, *args, **kwargs): + # """ + # Overridden so we can make sure the `Rapid` instance exists + # before going any further. + # """ + # self.pk = get_object_or_404(Rapid, pk=kwargs['pk']) + # return super().dispatch(request, *args, **kwargs) + def get_object(self, queryset=None): + obj, create = RapidCreationDefault.objects.get_or_create( + author=self.request.user + ) + + return obj + + def form_valid(self, form): + + model = form.save(commit=False) + + model.author = self.request.user + model.save() + + response = super().form_valid(form) + + return response + + # form.instance.author.add(self.request.user.id) + + +@login_required +def rapid_clone(request, pk): + new_item = get_object_or_404(Rapid, pk=pk) + new_item.pk = None # autogen a new pk (item_id) + # new_item.name = "Copy of " + new_item.name #need to change uniques + + form = RapidForm(request.POST or None, instance=new_item) + + image_formset = ImageFormSet() + answer_formset = AnswerFormSet() + + if form.is_valid(): + form.instance.author.add(request.user.id) + + # logger.debug(formset.is_valid()) + if image_formset.is_valid() and answer_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = obj + image_formset.save() + + answer_formset.instance = obj + answer_formset.save() + return response + else: + return super().form_invalid(form) + + context = { + "form": form, + "image_formset": image_formset, + "answer_formset": answer_formset + # other context + } + + return render(request, "rapids/rapid_form.html", context) + + +class RapidCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): + model = Rapid + form_class = RapidForm + + # Sending user object to the form, to verify which fields to display/remove (depending on group) + def get_form_kwargs(self): + kwargs = super(RapidCreateBase, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(RapidCreateBase, self).get_context_data(**kwargs) + if self.request.POST: + context["image_formset"] = ImageFormSet( + self.request.POST, self.request.FILES + ) + context["image_formset"].full_clean() + context["answer_formset"] = AnswerFormSet(self.request.POST) + context["answer_formset"].full_clean() + else: + context["image_formset"] = ImageFormSet() + context["answer_formset"] = AnswerFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + image_formset = context["image_formset"] + answer_formset = context["answer_formset"] + if image_formset.is_valid() and answer_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_formset.save() + answer_formset.instance = self.object + answer_formset.save() + # If the normal submit button is pressed we save as normal + if "submit" in self.request.POST: + return response + # else we redirect to the clone url + else: + return redirect("rapids:rapid_clone", pk=self.object.pk) + + else: + return super().form_invalid(form) + + +# @login_required +class RapidCreate(RapidCreateBase): + + # initial = {"laterality": Rapid.NONE} + + def get_initial(self): + if "pk" in self.kwargs: + initial = super().get_initial() + exam = get_object_or_404(Exam, pk=self.kwargs["pk"]) + + initial["exams"] = [exam.id] + + return initial + + ## There has to be a better way... + # try: + # s = (i.pk for i in self.request.user.rapid_default.site.all()) + # self.initial.update({"site": s}) + # except AttributeError: + # pass + # return self.initial + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + + # self.object = form.save(commit=False) + # self.object.save() + + # form.instance.author.add(self.request.user.id) + # return super().form_valid(form) + + +class RapidUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Rapid + form_class = RapidForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_form_kwargs(self): + kwargs = super(RapidUpdate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(RapidUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["image_formset"] = ImageFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["image_formset"].full_clean() + context["answer_formset"] = AnswerFormSet( + self.request.POST, instance=self.object + ) + context["answer_formset"].full_clean() + else: + context["image_formset"] = ImageFormSet(instance=self.object) + context["answer_formset"] = AnswerFormSet(instance=self.object) + return context + + def form_valid(self, form): + # save exam orders (there must be a better way to do this) + #exam_orders = {} + #for exam in self.object.exams.all(): + # exam_orders[exam] = list(exam.exam_questions.all()) + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + image_formset = context["image_formset"] + answer_formset = context["answer_formset"] + # logger.debug(formset.is_valid()) + if image_formset.is_valid() and answer_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_formset.save() + answer_formset.instance = self.object + answer_formset.save() + + # restore exam orders + #wanted_exams = form["exams"].data + #for exam in self.object.exams.all(): + # if exam in exam_orders and self.object in exam_orders[exam]: + # print(exam_orders[exam]) + # exam.exam_questions.set(exam_orders[exam]) + # exam.save() + + return response + else: + return super().form_invalid(form) + + +class RapidClone(RapidCreateBase): + """Clones a existing rapid""" + + def get_initial(self): + # print(self.request) + old_object = get_object_or_404(Rapid, pk=self.kwargs["pk"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + return initial_data + + +@login_required +@user_is_author_or_rapid_checker +def rapid_scrap(request, pk): + try: + rapid = Rapid.objects.get(pk=pk) + except Rapid.DoesNotExist: + raise Http404("Rapid does not exist") + + if ( + not request.user.groups.filter(name="rapid_checker").exists() + and request.user not in rapid.author.all() + ): + raise PermissionDenied() + + rapid.scrapped = not rapid.scrapped + rapid.save() + return HttpResponseRedirect(reverse("rapids:question_detail", args=(pk,))) + + +# @login_required +# def edit_abnormality_popup(request): +# instance = get_object_or_404(Abnormality, pk = pk) +# form = AbnormalityForm(request.POST or None) +# if form.is_valid(): +# instance = form.save() +# return HttpResponse('' % (instance.pk, instance)) +# return render(request, "rapids/create_simple.html", {'form': form}) + + +@login_required +def create_abnormality(request): + form = AbnormalityForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "rapids/create_simple.html", {"form": form, "name": "Abnormality"} + ) + + +@csrf_exempt +def get_abnormality_id(request): + if request.is_ajax(): + abnormality_name = request.GET["abnormality_name"] + abnormality_id = Abnormality.objects.get(name=abnormality_name).id + data = { + "abnormality_id": abnormality_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +@login_required +def create_examination(request): + form = ExaminationForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "rapids/create_simple.html", {"form": form, "name": "Examination"} + ) + + +@csrf_exempt +def get_examination_id(request): + if request.is_ajax(): + examination_name = request.GET["examination_name"] + examination_id = Examination.objects.get(name=examination_name).id + data = { + "examination_id": examination_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +@login_required +def create_region(request): + form = RegionForm(request.POST or None) + if form.is_valid(): + instance = form.save() + return HttpResponse( + '' + % (instance.pk, instance) + ) + return render( + request, "rapids/create_simple.html", {"form": form, "name": "Region"} + ) + + +@csrf_exempt +def get_region_id(request): + if request.is_ajax(): + region_name = request.GET["region_name"] + region_id = Region.objects.get(name=region_name).id + data = { + "region_id": region_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +class RapidView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Rapid + table_class = RapidTable + template_name = "rapids/view.html" + + filterset_class = RapidFilter + + +def loadJsonAnswer(answer): + eid = int(answer["eid"].split("/")[1]) + cid = int(answer["cid"]) + + # As access is not restricted make sure the data appears valid + if (not isinstance(cid, int)) or ( + not isinstance(eid, int) or (not isinstance(answer["ans"], str)) + ): + return False, JsonResponse( + {"success": False, "error": "cid or eid or answers not defined"} + ) + + # The model should catch invalid data but this should be less intensive + max_int = 999999999999999999999 + if cid > max_int or eid > max_int: + return False, JsonResponse({"success": False, "error": "invalid cid"}) + + exam = get_object_or_404(Exam, pk=eid) + + if not exam.check_cid_user(cid, answer["passcode"]): + return False, JsonResponse( + {"success": False, "error": "invalid cid / passcode"} + ) + + # if not exam.active: + # return False, JsonResponse( + # {"success": False, "error": "No active exam: {}".format(eid)} + # ) + + exiting_answers = CidUserAnswer.objects.filter( + question__id=answer["qid"], exam__id=eid, cid=cid + ) + + posted_answer = answer["ans"] + + ## qidn 1 does not hold answer data (but should always arrive prior to 2) + # if answer["qidn"] == "1": + # posted_answer = "" + + # Normal answers are just posted with the answer "Normal" + normal = False + if posted_answer == "Normal": + normal = True + + if not exiting_answers: + ans = CidUserAnswer(answer=posted_answer, normal=normal, cid=cid) + ans.question_id = answer["qid"] + ans.exam_id = eid + + ans.full_clean() + + ans.save() + else: + # Update an existing answer + # should never be more than one (famous last words) + ans = exiting_answers[0] + if answer["qidn"] == "1": + ans.normal = normal + ans.answer = "" + elif answer["qidn"] == "2" and not ans.normal: + ans.answer = posted_answer + ans.full_clean() + ans.save() + + return True, None + + +@login_required +def mark_all(request, exam_pk, sk): + return mark(request, exam_pk, sk, unmarked_exam_answers_only=False) + + +@login_required +def mark_review(request, exam_pk, sk): + return mark(request, exam_pk, sk, unmarked_exam_answers_only=False, review=True) + + +@login_required +def exam_scores_cid2(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + user_answers_and_marks = defaultdict(list) + user_answers_marks = defaultdict(list) + user_answers = defaultdict(list) + user_names = {} + + score_by_question = defaultdict(dict) + ans_by_question = defaultdict(dict) + unmarked = set() + + questions = exam.exam_questions.all() + + cid_user_answers = CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) + + cids = set() + + # Loop through all candidates + for cid_user_answer in cid_user_answers: + # Convoluted (probably...) + cid = cid_user_answer.cid + cids.add(cid) + s = cid_user_answer + user_names[cid] = cid + + q = cid_user_answer.question + + # if not s: + # # skip if no answer + # user_answers_marks[cid].append(0) + # user_answers[cid].append("") + # by_question[q].append(("", 0)) + # continue + if s.normal: + ans = "Normal" + else: + ans = s.answer + answer_score = s.get_answer_score() + if answer_score == "unmarked": + index = exam.get_question_index(q) + unmarked.add(index) + user_answers[cid].append(ans) + user_answers_marks[cid].append(answer_score) + user_answers_and_marks[cid].append((ans, answer_score)) + + score_by_question[q][cid] = answer_score + ans_by_question[q][cid] = ans + + user_scores = {} + user_scores_normalised = {} + for user in user_answers_marks: + user_scores[user] = sum( + [i for i in user_answers_marks[user] if i != "unmarked"] + ) + user_scores_normalised[user] = normaliseScore( + sum([i for i in user_answers_marks[user] if i != "unmarked"]) + ) + + user_scores_list = list(user_scores.values()) + + if len(user_scores_list) < 1: + mean = 0 + median = 0 + mode = 0 + fig_html = "" + else: + mean = statistics.mean(user_scores_list) + median = statistics.median(user_scores_list) + try: + mode = statistics.mode(user_scores_list) + except statistics.StatisticsError: + mode = "No unique mode" + + df = user_scores_list + fig = px.histogram( + df, + x=0, + title="{}: distribution of scores".format(exam), + labels={"0": "Score"}, + height=400, + width=600, + ) + fig_html = fig.to_html() + + max_score = len(questions) * 2 + + return render( + request, + "rapids/exam_scores_new.html", + { + "cids": sorted(cids), + "exam": exam, + "unmarked": unmarked, + "questions": questions, + "score_by_question": score_by_question, + "ans_by_question": ans_by_question, + "user_answers": dict(user_answers), + "user_answers_marks": dict(user_answers_marks), + "user_scores": user_scores, + "user_scores_normalised": user_scores_normalised, + "user_scores_list": user_scores_list, + "user_names": user_names, + "user_answers_and_marks": user_answers_and_marks, + "max_score": max_score, + "mean": mean, + "median": median, + "mode": mode, + "plot": fig_html, + }, + ) + + +# @user_passes_test(user_is_admin, login_url="/accounts/login") +@login_required +def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): + exam = get_object_or_404(Exam, pk=exam_pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + mark_url = "rapids:mark" + if review: + mark_url = "rapids:mark_review" + elif not unmarked_exam_answers_only: + mark_url = "rapids:mark_all" + + questions = exam.exam_questions.all() + + n = sk + + question_details = { + "total": len(questions), + "current": n + 1, + } + + try: + question = questions[sk] + except IndexError: + raise Http404("Exam question does not exist") + + if request.method == "POST": + + form = MarkRapidQuestionForm(request.POST) + # ******************************* + # TODO: convert to JSON request + # ******************************* + if form.is_valid(): + # If skip button is pressed skip the question without marking + if "skip" in request.POST: + return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) + + cd = form.cleaned_data + + marked_answers = json.loads(cd.get("marked_answers")) + + # This should probably use json (or something else...) + # ajax..... + to_run = ( + ("correct", Answer.MarkOptions.CORRECT), + ("half-correct", Answer.MarkOptions.HALF_MARK), + ("incorrect", Answer.MarkOptions.INCORRECT), + ) + + for status_string, status in to_run: + for ans in marked_answers[status_string]: + ans = ans.strip() + if ans == "": + continue + + a = Answer.objects.filter( + answer__iexact=ans, question_id=question.pk + ).first() + + if a is None: + a = Answer() + a.question_id = question.pk + a.answer = ans + + a.status = status + a.save() + + if "next" in request.POST: + return redirect(mark_url, exam_pk=exam_pk, sk=n + 1) + elif "previous" in request.POST: + return redirect(mark_url, exam_pk=exam_pk, sk=n - 1) + + # Reset user answers (relies on the functional javascript) + unmarked_user_answers = set() + else: + form = MarkRapidQuestionForm() + + # answers_dict = {} + + # for ans in question.answers.all(): + # answers_dict[ans.answer_compare] = ans + + correct_answers = [] + half_mark_answers = [] + incorrect_answers = [] + review_user_answers = [] + unmarked_answers_bool = False + unmarked_user_answers = [] + # this could be improved + if question.normal: + incorrect_answers = question.get_user_answers(exam_pk=exam_pk) + if "" in incorrect_answers: + incorrect_answers.remove("") + if "normal" in incorrect_answers: + incorrect_answers.remove("normal") + else: + if review: + unmarked_user_answers = question.get_user_answers( + exam_pk=exam_pk, include_normal=False + ) + elif unmarked_exam_answers_only: + unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk) + else: + unmarked_user_answers = question.get_unmarked_user_answers() + + if not unmarked_exam_answers_only: + correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT) + half_mark_answers = question.answers.filter( + status=Answer.MarkOptions.HALF_MARK + ) + incorrect_answers = question.answers.filter( + status=Answer.MarkOptions.INCORRECT + ) + + if review: + for ans in unmarked_user_answers: + # duplicated from user answer model.... + marked_ans = question.answers.filter(answer_compare__iexact=ans).first() + mark = "unmarked" + if marked_ans is not None: + if marked_ans.status == Answer.MarkOptions.CORRECT: + mark = "correct" + elif marked_ans.status == Answer.MarkOptions.HALF_MARK: + mark = "half-correct" + elif marked_ans.status == Answer.MarkOptions.INCORRECT: + mark = "incorrect" + if mark == "unmarked": + unmarked_answers_bool = True + review_user_answers.append((ans, mark)) + + return render( + request, + "rapids/mark.html", + { + "exam": exam, + "form": form, + "review": review, + "question": question, + "question_number": n, + "question_details": question_details, + "unmarked_answers_bool": unmarked_answers_bool, + "user_answers": unmarked_user_answers, + "review_user_answers": review_user_answers, + "correct_answers": correct_answers, + "half_mark_answers": half_mark_answers, + "incorrect_answers": incorrect_answers, + "unmarked_exam_answers_only": unmarked_exam_answers_only, + }, + ) + + +@login_required +def exam_scores_cid(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + questions = exam.exam_questions.all() + + cids = ( + CidUserAnswer.objects.filter(question__in=questions, exam__id=pk) + .order_by("cid") + .values_list("cid", flat=True) + .distinct() + ) + + user_answers_and_marks = defaultdict(list) + user_answers_marks = defaultdict(list) + user_answers = defaultdict(list) + user_names = {} + + by_question = defaultdict(list) + unmarked = set() + + # Loop through all candidates + for cid in cids: + # Convoluted (probably...) + user_names[cid] = cid + for q in questions: + # Get user answer + s = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() + + if not s: + # skip if no answer + user_answers_marks[cid].append(0) + user_answers[cid].append("") + by_question[q].append(("", 0)) + continue + elif s.normal: + ans = "Normal" + else: + ans = s.answer + answer_score = s.get_answer_score() + if answer_score == "unmarked": + index = exam.get_question_index(q) + unmarked.add(index) + user_answers[cid].append(ans) + user_answers_marks[cid].append(answer_score) + user_answers_and_marks[cid].append((ans, answer_score)) + + by_question[q].append((ans, answer_score)) + + user_scores = {} + user_scores_normalised = {} + for user in user_answers_marks: + user_scores[user] = sum( + [i for i in user_answers_marks[user] if i != "unmarked"] + ) + user_scores_normalised[user] = normaliseScore( + sum([i for i in user_answers_marks[user] if i != "unmarked"]) + ) + + user_scores_list = list(user_scores.values()) + + if len(user_scores_list) < 1: + mean = 0 + median = 0 + mode = 0 + fig_html = "" + else: + mean = statistics.mean(user_scores_list) + median = statistics.median(user_scores_list) + try: + mode = statistics.mode(user_scores_list) + except statistics.StatisticsError: + mode = "No unique mode" + + df = user_scores_list + fig = px.histogram( + df, + x=0, + title="{}: distribution of scores".format(exam), + labels={"0": "Score"}, + height=400, + width=600, + ) + fig_html = fig.to_html() + + max_score = len(questions) * 2 + + return render( + request, + "rapids/exam_scores.html", + { + "cids": cids, + "exam": exam, + "unmarked": unmarked, + "questions": questions, + "by_question": by_question, + "user_answers": dict(user_answers), + "user_answers_marks": dict(user_answers_marks), + "user_scores": user_scores, + "user_scores_normalised": user_scores_normalised, + "user_scores_list": user_scores_list, + "user_names": user_names, + "user_answers_and_marks": user_answers_and_marks, + "max_score": max_score, + "mean": mean, + "median": median, + "mode": mode, + "plot": fig_html, + }, + ) + + +def exam_scores_cid_user(request, pk, cid, passcode): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.exam_mode: + raise Http404("Packet not in exam mode") + + # TODO:Need some kind of test for cid + + questions = exam.exam_questions.all() + + answers_and_marks = [] + answers_marks = [] + answers = [] + + view_all_results = False + if request.user.groups.filter(name="view_all_results").exists(): + view_all_results = True + + for q in questions: + # Get user answer + user_answer = q.cid_user_answers.filter(cid=cid, exam__id=pk).first() + + if not user_answer or user_answer is None: + # skip if no answer + answers_marks.append(0) + # answers.append("") + answer_score = 0 + ans = "Not answered" + else: + if user_answer.normal: + ans = "Normal" + else: + ans = user_answer.answer + answer_score = user_answer.get_answer_score() + + correct_answer = q.get_primary_answer() + + if not exam.publish_results and not view_all_results: + correct_answer = "*****" + answer_score = 0 + answers.append(ans) + answers_marks.append(answer_score) + answers_and_marks.append((ans, answer_score, correct_answer)) + + if "unmarked" in answers_marks: + 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) + else: + total_score = sum(answers_marks) + + normalised_score = normaliseScore(total_score) + + max_score = len(questions) * 2 + + return render( + request, + "rapids/exam_scores_user.html", + { + "exam": exam, + "cid": cid, + "passcode": passcode, + "questions": questions, + "answers": answers, + "answers_marks": answers_marks, + "total_score": total_score, + "normalised_score": normalised_score, + "max_score": max_score, + "answers_and_marks": answers_and_marks, + "view_all_results": view_all_results, + }, + ) + + +class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): + model = Rapid + success_url = reverse_lazy("rapids:rapid_view") + + +GenericExamViews = ExamViews( + Exam, Rapid, Answer, CidUserAnswer, "rapids", "rapid", loadJsonAnswer +) + + +class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + return super().form_valid(form) + + +class ExamUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + return super().form_valid(form) + + +class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Exam + template_name = "exam_confirm_delete.html" + success_url = reverse_lazy("rapids:index") + + +class UserAnswerView(LoginRequiredMixin, DetailView): + model = CidUserAnswer + + +class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = CidUserAnswer + table_class = RapidUserAnswerTable + template_name = "rapids/user_answer_question_view.html" + + filterset_class = RapidUserAnswerFilter + + +class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): + model = CidUserAnswer + template_name = "user_answer_delete.html" + success_url = reverse_lazy("rapids:user_answer_table_view") + + +class LargeResultsSetPagination(PageNumberPagination): + page_size = 1000 + page_size_query_param = "page_size" + max_page_size = 10000 + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 100 + page_size_query_param = "page_size" + max_page_size = 1000 + + +class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): + # queryset = Exam.objects.all().order_by('name') + serializer_class = ExamSerializer + + def get_queryset(self): + """ + This view should return a list exams available to a user. + """ + user = self.request.user + if user.groups.filter(name="rapid_checker").exists(): + return Exam.objects.all() + + return Exam.objects.filter(author__id=user.id) + + +class QuestionAnswerViewSet(viewsets.ModelViewSet): + queryset = Answer.objects.all() # .order_by('name') + serializer_class = QuestionAnswerSerializer + permission_classes = [permissions.IsAuthenticated] + + +class UserAnswerViewSet(viewsets.ModelViewSet): + queryset = CidUserAnswer.objects.all() # .order_by('name') + serializer_class = UserAnswerSerializer + permission_classes = [permissions.IsAuthenticated] + + +class RapidViewSet( + RevisionMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = Rapid.objects.all() # .order_by('name') + serializer_class = RapidSerializer + permission_classes = [permissions.IsAuthenticated] + pagination_class = StandardResultsSetPagination + + +class RapidLateralityViewSet( + RevisionMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + queryset = Rapid.objects.all() # .order_by('name') + serializer_class = RapidLateralitySerializer + permission_classes = [permissions.IsAuthenticated] + pagination_class = StandardResultsSetPagination + + +# class AddSuggestedAnswer(LoginRequiredMixin, CreateView): +# model = QuestionNote +# form_class = QuestionNoteForm +# +# #fields = ("author", "note_type", "note") +# +# +# def get_form_kwargs(self): +# kwargs = super(AddQuestionNote, self).get_form_kwargs() +# # update the kwargs for the form init method with yours +# kwargs.update(self.kwargs) # self.kwargs contains all url conf params +# return kwargs +# +# def get_initial(self): +# pk = self.kwargs["pk"] +# question_type = self.kwargs["question_type"] +# if question_type == "rapid": +# question = Rapid +# content_type = ContentType.objects.get(model="rapid") +# elif question_type == "anatomy": +# question = AnatomyQuestion +# content_type = ContentType.objects.get(model="anatomyquestion") +# elif question_type == "long": +# question = Long +# content_type = ContentType.objects.get(model="long") +# else: +# raise PermissionError() +# +# get_object_or_404(question, pk=pk) +# return { "content_type" : content_type, +# "object_id" : pk } + +GenericViews = GenericViewBase("rapids", Rapid, CidUserAnswer, Exam) + + +@user_is_author_or_rapid_checker +def question_save_annotation(request, pk): + if request.is_ajax() and request.method == "POST": + question = get_object_or_404(Rapid, pk=pk) + + image_annotations = json.loads(request.POST.get("annotation")) + + question_images = question.images.filter(feedback_image=False) + + for n, image in enumerate(question_images): + annotation = image_annotations[n] + if annotation != "undefined": + image.image_annotations = annotation + else: + image.image_annotations = "" + image.save() + + data = {"status": "success"} + return JsonResponse(data, status=200) + else: + data = {"status": "error"} + return JsonResponse(data, status=400) + + +class ExamClone(ExamCloneMixin, ExamCreate): + """Clone exam view""" + + +@login_required +def get_question_by_hash(request): + if request.is_ajax() and request.method == "POST": + + hash = request.POST.get("hash") + + try: + question = RapidImage.objects.get(image_md5_hash=hash) + data = { + "status": "success", + "id": question.pk, + "url": question.rapid.get_absolute_url(), + } + return JsonResponse(data, status=200) + except RapidImage.DoesNotExist: + data = {"status": "success", "id": False} + return JsonResponse(data, status=200) + + +# TODO: better permissions +@login_required +def question_json_unbased(request, pk): + """ + No (file based) caching is enabled for unbased quesitons + """ + question = get_object_or_404(Rapid, pk=pk) + + question_json = question.get_question_json(based=False) + return JsonResponse(question_json) + + +@login_required +def question_json(request, pk): + question = get_object_or_404(Rapid, pk=pk) + + question_json = question.get_question_json(based=True) + return JsonResponse(question_json) + + +@login_required +def question_viewer(request): + # question = get_object_or_404(Rapid, pk=pk) + + ids = request.GET.get("ids").split(",") + + return render( + request, + "rapids/question_viewer.html", + { + "ids": ids, + }, + ) diff --git a/sbas/templates/sbas/exam_form.html b/sbas/templates/sbas/exam_form.html index 58de3198..4014a0b2 100755 --- a/sbas/templates/sbas/exam_form.html +++ b/sbas/templates/sbas/exam_form.html @@ -1,16 +1,16 @@ -{% extends "sbas/base.html" %} - -{% block js %} - {{ form.media }} -{% endblock %} - -{% block content %} -

Add Exam

-
- {% csrf_token %} - - {{ form.as_table }} -
- -
-{% endblock %} +{% extends "sbas/base.html" %} + +{% block js %} + {{ form.media }} +{% endblock %} + +{% block content %} +

Add Exam

+
+ {% csrf_token %} + + {{ form.as_table }} +
+ +
+{% endblock %} diff --git a/sbas/urls.py b/sbas/urls.py index 96954ebd..61da5142 100644 --- a/sbas/urls.py +++ b/sbas/urls.py @@ -1,77 +1,77 @@ -from django.urls import path, include - -from . import views -from django.views.decorators.cache import cache_page - -app_name = "sbas" - -urlpatterns = [ - # path('', views.question_list, name='question_list'), - path("", views.GenericExamViews.index, name="index"), - path("question/", views.QuestionView.as_view(), name="question_view"), - path("question//", views.question_detail, name="question_detail"), - # path("question/create/", views.QuestionCreate.as_view(), name="anatomy_question_create"), - # path("question//update", views.QuestionUpdate.as_view(), name="anatomy_question_update"), - # path("exam///mark", views.mark, name="mark"), - # path("exam//mark", views.mark_overview, name="mark_overview"), - path( - "exam/////take", - views.exam_take, - name="exam_take", - ), - path("exam//start", views.exam_start, name="exam_start"), - path( - "exam////finish", - views.exam_take_overview, - name="exam_take_overview", - ), - path("exam//", views.GenericExamViews.exam_overview, name="exam_overview"), - path( - "exam//scores", - views.GenericExamViews.exam_scores_cid, - name="exam_scores_cid", - ), - path( - "exam//scores///", - views.exam_scores_cid_user, - name="exam_scores_cid_user", - ), - path( - "exam//toggle_active", - views.GenericExamViews.exam_toggle_active, - name="exam_toggle_active", - ), - path( - "exam//toggle_results_published", - views.GenericExamViews.exam_toggle_results_published, - name="exam_toggle_results_published", - ), - # path("exam/submit", views.GenericExamViews.postExamAnswers, name="exam_answers_submit"), - path("exam/", views.GenericExamViews.exam_list, name="exam_list"), - path("exam/all", views.GenericExamViews.exam_list_all, name="exam_list_all"), - path("exam//clone", views.ExamClone.as_view(), name="exam_clone"), - path("exam//cids", views.GenericExamViews.exam_cids, name="exam_cids"), - path("exam/available", views.active_exams, name="active_exams"), - path("exam//update", views.ExamUpdate.as_view(), name="exam_update"), - path("exam//delete", views.ExamDelete.as_view(), name="exam_delete"), - # path("exam/json/", views.exam_json, name="exam_json"), - # path("exam/json//recreate", views.exam_json_recreate, name="exam_json_recreate"), - path( - "user_answers/", - views.UserAnswerTableView.as_view(), - name="user_answer_table_view", - ), - path( - "user_answers/", views.UserAnswerView.as_view(), name="user_answer_view" - ), - path( - "user_answers//delete", - views.UserAnswerDelete.as_view(), - name="user_answer_delete", - ), - path( - "user_answers/delete", - views.GenericViews.user_answer_delete_multiple, - name="user_answer_delete_multiple", - ), -] +from django.urls import path, include + +from . import views +from django.views.decorators.cache import cache_page + +app_name = "sbas" + +urlpatterns = [ + # path('', views.question_list, name='question_list'), + path("", views.GenericExamViews.index, name="index"), + path("question/", views.QuestionView.as_view(), name="question_view"), + path("question//", views.question_detail, name="question_detail"), + # path("question/create/", views.QuestionCreate.as_view(), name="anatomy_question_create"), + # path("question//update", views.QuestionUpdate.as_view(), name="anatomy_question_update"), + # path("exam///mark", views.mark, name="mark"), + # path("exam//mark", views.mark_overview, name="mark_overview"), + path( + "exam/////take", + views.exam_take, + name="exam_take", + ), + path("exam//start", views.exam_start, name="exam_start"), + path( + "exam////finish", + views.exam_take_overview, + name="exam_take_overview", + ), + path("exam//", views.GenericExamViews.exam_overview, name="exam_overview"), + path( + "exam//scores", + views.GenericExamViews.exam_scores_cid, + name="exam_scores_cid", + ), + path( + "exam//scores///", + views.exam_scores_cid_user, + name="exam_scores_cid_user", + ), + path( + "exam//toggle_active", + views.GenericExamViews.exam_toggle_active, + name="exam_toggle_active", + ), + path( + "exam//toggle_results_published", + views.GenericExamViews.exam_toggle_results_published, + name="exam_toggle_results_published", + ), + # path("exam/submit", views.GenericExamViews.postExamAnswers, name="exam_answers_submit"), + path("exam/", views.GenericExamViews.exam_list, name="exam_list"), + path("exam/all", views.GenericExamViews.exam_list_all, name="exam_list_all"), + path("exam//clone", views.ExamClone.as_view(), name="exam_clone"), + path("exam//cids", views.GenericExamViews.exam_cids, name="exam_cids"), + path("exam/available", views.active_exams, name="active_exams"), + path("exam//update", views.ExamUpdate.as_view(), name="exam_update"), + path("exam//delete", views.ExamDelete.as_view(), name="exam_delete"), + # path("exam/json/", views.exam_json, name="exam_json"), + # path("exam/json//recreate", views.exam_json_recreate, name="exam_json_recreate"), + path( + "user_answers/", + views.UserAnswerTableView.as_view(), + name="user_answer_table_view", + ), + path( + "user_answers/", views.UserAnswerView.as_view(), name="user_answer_view" + ), + path( + "user_answers//delete", + views.UserAnswerDelete.as_view(), + name="user_answer_delete", + ), + path( + "user_answers/delete", + views.GenericViews.user_answer_delete_multiple, + name="user_answer_delete_multiple", + ), +] diff --git a/sbas/views.py b/sbas/views.py index 2a52e98b..4e6cee31 100644 --- a/sbas/views.py +++ b/sbas/views.py @@ -1,411 +1,411 @@ -from reversion.views import RevisionMixin -from generic.mixins import SuperuserRequiredMixin -from django.views.generic.detail import DetailView -from generic.models import CidUser -from sbas.forms import CidUserAnswerForm, ExamForm -from django.shortcuts import render, get_object_or_404, redirect -from django.views.decorators.csrf import csrf_exempt -from django import forms - -# from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required, user_passes_test -from django.contrib.auth.models import User - -from django.contrib.auth.mixins import LoginRequiredMixin - -from django.views.generic.edit import CreateView, UpdateView, DeleteView -from django.views.generic import ListView - -from django.db.models.functions import Lower - -from django.core.cache import cache - -from django.urls import reverse_lazy, reverse - -from django.http import Http404, JsonResponse - -from django.http import HttpResponseRedirect, HttpResponse - -from .models import Question, Category, Exam, CidUserAnswer - -from django_tables2 import SingleTableView, SingleTableMixin -from django_filters.views import FilterView - -from collections import defaultdict -import os -import base64 -import mimetypes -import json -import statistics -import plotly.express as px - -from generic.views import ExamCloneMixin, ExamViews, GenericViewBase - -from rest_framework import viewsets, permissions -from rest_framework.pagination import PageNumberPagination - -from django.core.exceptions import PermissionDenied - -from .tables import QuestionTable, UserAnswerTable -from .filters import QuestionFilter, UserAnswerFilter - - -class AuthorOrCheckerRequiredMixin(object): - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user.groups.filter(name="sbas_checker").exists(): - return obj - if self.request.user not in obj.author.all(): - raise PermissionDenied() # or Http404 - return obj - - -def question_list(request): - questions = Question.objects.all() - return render(request, "sbas/question_list.html", {"questions": questions}) - - -@login_required -def question_detail(request, pk): - question = get_object_or_404(Question, pk=pk) - return render(request, "sbas/question_detail.html", {"question": question}) - - -def active_exams(request): - exams = Exam.objects.all() - print(exams) - - active_exams = [] - - for exam in exams: - if exam.active: - active_exams.append(exam) - return render(request, "sbas/available_exam_list.html", {"exams": active_exams}) - - -def exam_scores_cid_user(request, pk, cid, passcode): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.check_cid_user(cid, passcode, request): - raise Http404("Error accessing exam") - - questions = exam.exam_questions.all() - - answers_and_marks = [] - answers_marks = [] - answers = [] - - view_all_results = False - if request.user.groups.filter(name="view_all_results").exists(): - view_all_results = True - - for q in questions: - # Get user answer - user_answer = q.cid_user_answers.filter(cid=cid).first() - - if user_answer is None: - answer_score = 0 - ans = "" - else: - answer_score = user_answer.get_answer_score() - ans = user_answer.get_answer() - - correct_answer = q.get_correct_answer() - - if not exam.publish_results and not view_all_results: - correct_answer = "*****" - answer_score = 0 - answers.append(ans) - answers_marks.append(answer_score) - - merged_ans = (ans, answer_score, correct_answer) - answers_and_marks.append((q, *merged_ans)) - - total_score = sum(answers_marks) - - max_score = len(questions) - - return render( - request, - "sbas/exam_scores_user.html", - { - "exam": exam, - "cid": cid, - "passcode": passcode, - "questions": questions, - "answers": answers, - "answers_marks": answers_marks, - "total_score": total_score, - "max_score": max_score, - "answers_and_marks": answers_and_marks, - "view_all_results": view_all_results, - }, - ) - - -def exam_start(request, pk): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.active: - raise Http404("Exam not found") - - return render( - request, - "sbas/exam_start.html", - { - "exam": exam, - }, - ) - - -def exam_take_overview(request, pk, cid, passcode): - exam = get_object_or_404(Exam, pk=pk) - - if not exam.active: - raise Http404("Exam not found") - - if not exam.check_cid_user(cid, passcode): - raise Http404("Error accessing exam") - - questions = exam.exam_questions.all() - - answers = CidUserAnswer.objects.filter(cid=cid, exam=exam) - - answer_question_map = {} - for ans in answers: - answer_question_map[ans.question] = ans - - question_answer_tuples = [] - answer_count = 0 - for q in questions: - if q in answer_question_map and answer_question_map[q].answer: - question_answer_tuples.append((q, answer_question_map[q])) - answer_count += 1 - else: - question_answer_tuples.append((q, None)) - - c = CidUser.objects.filter(cid=cid).first() - cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c) - - return render( - request, - "sbas/exam_take_overview.html", - { - "exam": exam, - "cid": cid, - "question_answer_tuples": question_answer_tuples, - "answer_count": answer_count, - "exam_length": len(questions), - "passcode": passcode, - "cid_user_exam": cid_user_exam, - }, - ) - - -def exam_take(request, pk, sk, cid, passcode): - - exam = get_object_or_404(Exam, pk=pk) - - if not exam.active: - raise Http404("Exam not found") - - if not exam.check_cid_user(cid, passcode): - raise Http404("Error accessing exam") - - c = CidUser.objects.filter(cid=cid).first() - cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c) - - question = exam.exam_questions.all()[sk] - - exam_length = len(exam.exam_questions.all()) - - pos = exam.get_question_index(question) - - answer = question.cid_user_answers.filter(cid=cid, exam=exam).first() - - if request.method == "POST": - if answer: - form = CidUserAnswerForm(request.POST, instance=answer) - else: - form = CidUserAnswerForm(request.POST) - if form.is_valid(): - answer = form.save(commit=False) - answer.cid = cid - answer.question = question - answer.exam = exam - # answer.published_date = timezone.now() - answer.save() - - cid_user_exam.end_time = timezone.now() - cid_user_exam.save() - - kwargs = {"pk": pk, "cid": cid, "passcode": passcode} - - if "next" in request.POST: - return redirect("sbas:exam_take", sk=pos + 1, **kwargs) - elif "previous" in request.POST: - return redirect("sbas:exam_take", sk=pos - 1, **kwargs) - elif "finish" in request.POST: - return redirect("sbas:exam_take_overview", **kwargs) - elif "goto" in request.POST: - return redirect( - "sbas:exam_take", sk=int(request.POST.get("goto")), **kwargs - ) - else: - form = CidUserAnswerForm(instance=answer) - - previous = -1 - if sk > 0: - previous = sk - 1 - next = sk + 1 - if sk == exam_length - 1: - next = False - - saved_answer = False - if answer is not None: - saved_answer = answer.answer - - return render( - request, - "sbas/exam_take.html", - { - "form": form, - "cid": cid, - "exam": exam, - "question": question, - "next": next, - "previous": previous, - "exam_length": exam_length, - "pos": pos, - "saved_answer": saved_answer, - "passcode": passcode, - "cid_user_exam": cid_user_exam, - }, - ) - - -def loadJsonAnswer(answer): - pass - - -GenericExamViews = ExamViews( - Exam, Question, None, CidUserAnswer, "sbas", "sbas", loadJsonAnswer -) - - -class QuestionView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = Question - table_class = QuestionTable - template_name = "sbas/question_view.html" - - filterset_class = QuestionFilter - - -class UserAnswerView(LoginRequiredMixin, DetailView): - model = CidUserAnswer - - -class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView): - model = CidUserAnswer - table_class = UserAnswerTable - template_name = "sbas/user_answer_question_view.html" - - filterset_class = UserAnswerFilter - - -class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): - model = CidUserAnswer - template_name = "user_answer_delete.html" - success_url = reverse_lazy("sbas:user_answer_table_view") - - -# @user_passes_test(lambda u: u.is_superuser) -# def user_answer_delete_multiple(request): -# if request.is_ajax(): -# print(request.POST["answer_ids"]) -# answer_ids = json.loads(request.POST["answer_ids"]) -# -# # We could probably delete them all at once.... -# for id in answer_ids: -# CidUserAnswer.objects.get(pk=id).delete() -# return JsonResponse({"status": "success"}) -# return JsonResponse({"status": "error"}) - - -GenericViews = GenericViewBase("sbas", Question, CidUserAnswer, Exam) - - -class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): - model = Question - success_url = reverse_lazy("sbas:question_list") - - -class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - return super().form_valid(form) - - -class ExamUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) - return super().form_valid(form) - - -# class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): -# # queryset = Exam.objects.all().order_by('name') -# serializer_class = ExamSerializer -# permission_classes = [permissions.IsAuthenticated] -# -# def get_queryset(self): -# """ -# This view should return a list exams available to a user. -# """ -# user = self.request.user -# if user.groups.filter(name="sbas_checker").exists(): -# return Exam.objects.all() -# -# return Exam.objects.filter(author__id=user.id) - - -class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Exam - template_name = "exam_confirm_delete.html" - success_url = reverse_lazy("sbas:index") - - -class ExamClone(ExamCloneMixin, ExamCreate): - """Clone exam view""" - -class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): - model = Exam - template_name = "exam_confirm_delete.html" - success_url = reverse_lazy("sbas:index") - - -class ExamUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView -): - model = Exam - form_class = ExamForm - - def form_valid(self, form): - self.object = form.save(commit=False) - self.object.save() - - form.instance.author.add(self.request.user.id) +from reversion.views import RevisionMixin +from generic.mixins import SuperuserRequiredMixin +from django.views.generic.detail import DetailView +from generic.models import CidUser +from sbas.forms import CidUserAnswerForm, ExamForm +from django.shortcuts import render, get_object_or_404, redirect +from django.views.decorators.csrf import csrf_exempt +from django import forms + +# from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User + +from django.contrib.auth.mixins import LoginRequiredMixin + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView + +from django.db.models.functions import Lower + +from django.core.cache import cache + +from django.urls import reverse_lazy, reverse + +from django.http import Http404, JsonResponse + +from django.http import HttpResponseRedirect, HttpResponse + +from .models import Question, Category, Exam, CidUserAnswer + +from django_tables2 import SingleTableView, SingleTableMixin +from django_filters.views import FilterView + +from collections import defaultdict +import os +import base64 +import mimetypes +import json +import statistics +import plotly.express as px + +from generic.views import ExamCloneMixin, ExamViews, GenericViewBase + +from rest_framework import viewsets, permissions +from rest_framework.pagination import PageNumberPagination + +from django.core.exceptions import PermissionDenied + +from .tables import QuestionTable, UserAnswerTable +from .filters import QuestionFilter, UserAnswerFilter + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user.groups.filter(name="sbas_checker").exists(): + return obj + if self.request.user not in obj.author.all(): + raise PermissionDenied() # or Http404 + return obj + + +def question_list(request): + questions = Question.objects.all() + return render(request, "sbas/question_list.html", {"questions": questions}) + + +@login_required +def question_detail(request, pk): + question = get_object_or_404(Question, pk=pk) + return render(request, "sbas/question_detail.html", {"question": question}) + + +def active_exams(request): + exams = Exam.objects.all() + print(exams) + + active_exams = [] + + for exam in exams: + if exam.active: + active_exams.append(exam) + return render(request, "sbas/available_exam_list.html", {"exams": active_exams}) + + +def exam_scores_cid_user(request, pk, cid, passcode): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.check_cid_user(cid, passcode, request): + raise Http404("Error accessing exam") + + questions = exam.exam_questions.all() + + answers_and_marks = [] + answers_marks = [] + answers = [] + + view_all_results = False + if request.user.groups.filter(name="view_all_results").exists(): + view_all_results = True + + for q in questions: + # Get user answer + user_answer = q.cid_user_answers.filter(cid=cid).first() + + if user_answer is None: + answer_score = 0 + ans = "" + else: + answer_score = user_answer.get_answer_score() + ans = user_answer.get_answer() + + correct_answer = q.get_correct_answer() + + if not exam.publish_results and not view_all_results: + correct_answer = "*****" + answer_score = 0 + answers.append(ans) + answers_marks.append(answer_score) + + merged_ans = (ans, answer_score, correct_answer) + answers_and_marks.append((q, *merged_ans)) + + total_score = sum(answers_marks) + + max_score = len(questions) + + return render( + request, + "sbas/exam_scores_user.html", + { + "exam": exam, + "cid": cid, + "passcode": passcode, + "questions": questions, + "answers": answers, + "answers_marks": answers_marks, + "total_score": total_score, + "max_score": max_score, + "answers_and_marks": answers_and_marks, + "view_all_results": view_all_results, + }, + ) + + +def exam_start(request, pk): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.active: + raise Http404("Exam not found") + + return render( + request, + "sbas/exam_start.html", + { + "exam": exam, + }, + ) + + +def exam_take_overview(request, pk, cid, passcode): + exam = get_object_or_404(Exam, pk=pk) + + if not exam.active: + raise Http404("Exam not found") + + if not exam.check_cid_user(cid, passcode): + raise Http404("Error accessing exam") + + questions = exam.exam_questions.all() + + answers = CidUserAnswer.objects.filter(cid=cid, exam=exam) + + answer_question_map = {} + for ans in answers: + answer_question_map[ans.question] = ans + + question_answer_tuples = [] + answer_count = 0 + for q in questions: + if q in answer_question_map and answer_question_map[q].answer: + question_answer_tuples.append((q, answer_question_map[q])) + answer_count += 1 + else: + question_answer_tuples.append((q, None)) + + c = CidUser.objects.filter(cid=cid).first() + cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c) + + return render( + request, + "sbas/exam_take_overview.html", + { + "exam": exam, + "cid": cid, + "question_answer_tuples": question_answer_tuples, + "answer_count": answer_count, + "exam_length": len(questions), + "passcode": passcode, + "cid_user_exam": cid_user_exam, + }, + ) + + +def exam_take(request, pk, sk, cid, passcode): + + exam = get_object_or_404(Exam, pk=pk) + + if not exam.active: + raise Http404("Exam not found") + + if not exam.check_cid_user(cid, passcode): + raise Http404("Error accessing exam") + + c = CidUser.objects.filter(cid=cid).first() + cid_user_exam = exam.get_or_create_cid_user_exam(cid_user=c) + + question = exam.exam_questions.all()[sk] + + exam_length = len(exam.exam_questions.all()) + + pos = exam.get_question_index(question) + + answer = question.cid_user_answers.filter(cid=cid, exam=exam).first() + + if request.method == "POST": + if answer: + form = CidUserAnswerForm(request.POST, instance=answer) + else: + form = CidUserAnswerForm(request.POST) + if form.is_valid(): + answer = form.save(commit=False) + answer.cid = cid + answer.question = question + answer.exam = exam + # answer.published_date = timezone.now() + answer.save() + + cid_user_exam.end_time = timezone.now() + cid_user_exam.save() + + kwargs = {"pk": pk, "cid": cid, "passcode": passcode} + + if "next" in request.POST: + return redirect("sbas:exam_take", sk=pos + 1, **kwargs) + elif "previous" in request.POST: + return redirect("sbas:exam_take", sk=pos - 1, **kwargs) + elif "finish" in request.POST: + return redirect("sbas:exam_take_overview", **kwargs) + elif "goto" in request.POST: + return redirect( + "sbas:exam_take", sk=int(request.POST.get("goto")), **kwargs + ) + else: + form = CidUserAnswerForm(instance=answer) + + previous = -1 + if sk > 0: + previous = sk - 1 + next = sk + 1 + if sk == exam_length - 1: + next = False + + saved_answer = False + if answer is not None: + saved_answer = answer.answer + + return render( + request, + "sbas/exam_take.html", + { + "form": form, + "cid": cid, + "exam": exam, + "question": question, + "next": next, + "previous": previous, + "exam_length": exam_length, + "pos": pos, + "saved_answer": saved_answer, + "passcode": passcode, + "cid_user_exam": cid_user_exam, + }, + ) + + +def loadJsonAnswer(answer): + pass + + +GenericExamViews = ExamViews( + Exam, Question, None, CidUserAnswer, "sbas", "sbas", loadJsonAnswer +) + + +class QuestionView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Question + table_class = QuestionTable + template_name = "sbas/question_view.html" + + filterset_class = QuestionFilter + + +class UserAnswerView(LoginRequiredMixin, DetailView): + model = CidUserAnswer + + +class UserAnswerTableView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = CidUserAnswer + table_class = UserAnswerTable + template_name = "sbas/user_answer_question_view.html" + + filterset_class = UserAnswerFilter + + +class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): + model = CidUserAnswer + template_name = "user_answer_delete.html" + success_url = reverse_lazy("sbas:user_answer_table_view") + + +# @user_passes_test(lambda u: u.is_superuser) +# def user_answer_delete_multiple(request): +# if request.is_ajax(): +# print(request.POST["answer_ids"]) +# answer_ids = json.loads(request.POST["answer_ids"]) +# +# # We could probably delete them all at once.... +# for id in answer_ids: +# CidUserAnswer.objects.get(pk=id).delete() +# return JsonResponse({"status": "success"}) +# return JsonResponse({"status": "error"}) + + +GenericViews = GenericViewBase("sbas", Question, CidUserAnswer, Exam) + + +class QuestionDelete(AuthorOrCheckerRequiredMixin, DeleteView): + model = Question + success_url = reverse_lazy("sbas:question_list") + + +class ExamCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + return super().form_valid(form) + + +class ExamUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + return super().form_valid(form) + + +# class ExamViewSet(RevisionMixin, viewsets.ModelViewSet): +# # queryset = Exam.objects.all().order_by('name') +# serializer_class = ExamSerializer +# permission_classes = [permissions.IsAuthenticated] +# +# def get_queryset(self): +# """ +# This view should return a list exams available to a user. +# """ +# user = self.request.user +# if user.groups.filter(name="sbas_checker").exists(): +# return Exam.objects.all() +# +# return Exam.objects.filter(author__id=user.id) + + +class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Exam + template_name = "exam_confirm_delete.html" + success_url = reverse_lazy("sbas:index") + + +class ExamClone(ExamCloneMixin, ExamCreate): + """Clone exam view""" + +class ExamDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Exam + template_name = "exam_confirm_delete.html" + success_url = reverse_lazy("sbas:index") + + +class ExamUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Exam + form_class = ExamForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) return super().form_valid(form) \ No newline at end of file diff --git a/static/css/anatomy.css b/static/css/anatomy.css index f6f0033f..8ae9a1f9 100644 --- a/static/css/anatomy.css +++ b/static/css/anatomy.css @@ -1,831 +1,831 @@ -body { - color: gray; - background-color: black; -} - -a, -a:link { - color: #3282b8; -} - -.warn { - color: red; -} - -.answer-list { - font-size: 20px; - user-select: none; - -webkit-user-select: none; - /* Chrome/Safari */ - -moz-user-select: none; - /* Firefox */ - -ms-user-select: none; - /* IE10+ */ -} - -.answer-list .answer:hover { - z-index: 99999; - position: relative; - background: black; -} - -.answer-list .correct { - /* color: green; */ - color: lightblue; -} - -.answer-list .correct::after { - content: " [Score = 2]"; - font-size: small; -} - -.answer-list.rapid .correct::after { - content: " [Score = 2]"; -} - -.answer-list .half-correct { - color: yellow; -} - -.answer-list .half-correct::after { - content: " [Score = 1]"; - font-size: small; -} - -.answer-list.rapid .half-correct::after { - content: " [Score = 1]"; -} - -.answer-list .incorrect { - color: red; -} - -.answer-list .incorrect::after { - content: " [Score = 0]"; - font-size: small; -} - -.answer-list .not-marked { - color: white; -} - -.key { - font-size: 20; -} - -.hide { - display: none; -} - -.answered { - /* color: green; */ - color: lightblue; -} - -.unanswered { - color: gray; -} - -#question-list { - list-style: none; - float: right; - padding: 1px; - border: 1px solid gray; - /*max-height: 80%;*/ - height: 100%; - overflow-y: scroll; -} - -.flagged { - color: red; -} - -.not-flagged {} - -button { - text-decoration: none; - display: inline-block; - padding: 0.35em 1.2em; - border: 0.1em solid #52057b; - margin: 0 0.3em 0.3em 0; - border-radius: 0.12em; - box-sizing: border-box; - text-decoration: none; - font-family: 'Roboto', sans-serif; - font-weight: 300; - color: #a600ff; - text-align: center; - transition: all 0.2s; - background-color: transparent; -} - -button:hover { - color: white; - background-color: #52057b; -} - -button a { - text-decoration: none; - color: inherit; -} - -.main-nav-header { - /* max-width: 50%; */ - float: right; -} - -.main-nav-header span { - float: right; - padding-left: 10px; -} - -.top-bar-link {} - -.marking-list pre { - display: inline; -} - -.save { - display: inline-block; - padding: 0.35em 1.2em; - border: 0.1em solid #ffffff; - margin: 0 0.3em 0.3em 0; - border-radius: 0.12em; - box-sizing: border-box; - text-decoration: none; - font-family: "Roboto", sans-serif; - font-weight: 300; - color: #ffffff; - text-align: center; - transition: all 0.2s; - background-color: transparent; -} - -.save:hover { - color: #000000; - background-color: #ffffff; -} - -#stats-plot { - width: 50%; - float: right; -} - -#dicom-image { - width: 100%; - height: 600px; -} - -.dicom-viewer { - width: 100%; - height: 600px; -} - -.rapid-dicom-viewer { - width: 400px; - height: 600px; - position: fixed; - top: 30px; - right: 0px; - float: right; -} - -.marking-dicom { - position: fixed; - right: 0px; - bottom: 0px; - width: 45%; - height: 90%; - z-index: 1; -} - -.cornerstone-canvas { - height: 100%; -} - -.marking {} - -#question-mark-list { - padding: 20px -} - -#full-question-list { - counter-reset: question-list; - /* Set a counter named 'section', and its initial value is 0. */ -} - -#full-question-list li { - padding-bottom: 20px; -} - -#full-question-list li::before { - counter-increment: question-list; - content: counter(question-list) " "; - padding-right: 0.5em; -} - -#full-question-list img { - float: left; - padding-right: 20px; - padding-left: 10px; -} - -#question-mark-list a { - color: darkgray; -} - -#question-mark-list .unmarked a { - color: purple; -} - -#question-mark-list li { - padding-bottom: 5px; -} - -#start-marking-button { - float: right; - position: sticky; - top: 30px; -} - -#save-annotations { - position: absolute; - left: 0; -} - -.drop-target { - width: 100%; - width: 100vw; - height: 100px; - text-align: center; - font-size: larger; - position: sticky; - bottom: 0; - left: 0%; - right: 50%; - margin-left: -50vw; - margin-right: -50vw; -} - -#drop-container { - border: 1px dashed white; -} - -.submit-button { - position: sticky; - bottom: 0; -} - -#feedback-drop-target { - float: right; - height: 100%; - width: 30%; -} - -.drop-target-active { - background-color: #427aa1; -} - -#drop-filenames span { - font-size: xx-small; - color: gray; - padding-right: 1em; - -} - -.image-formset { - border: 1px solid white; -} - -select, -input { - background-color: #05668d; - color: white; - border: none; -} - -table.table { - color: white; -} - -#view-filter-options { - /* float: right; */ -} - -.rapid-img { - width: 300px; - height: 300px; -} - -.feedback-img { - border: 1px solid #05668d; -} - -.uploading { - margin-top: -20px; - height: 75%; - /* float: right; */ - position: absolute; - display: inline; - max-width: 100px; -} - -/* img.uploading:hover { - height: 95%; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%) -} */ - -.image-ident-warning { - border: 2px solid red; -} - - -.image-ident-loading { - background: rgba(142, 68, 173, 1); - box-shadow: 0 0 0 0 rgba(142, 68, 173, 1); - animation: pulse-purple 2s infinite; -} - -@keyframes pulse-purple { - 0% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(142, 68, 173, 0.7); - } - - 70% { - transform: scale(1); - box-shadow: 0 0 0 10px rgba(142, 68, 173, 0); - } - - 100% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(142, 68, 173, 0); - } -} - -#annotation-json-content { - display: none; -} - -.cid-score-table { - max-width: 400px; -} - -.user-answer-score-0 { - color: red; -} - -.user-answer-score-1 { - color: yellow; -} - -.user-answer-score-2 { - /* color: green; */ - color: lightblue; -} - -.rapid-ans.user-answer-score-2 { - color: lightblue; -} - -.rapid-ans.user-answer-score-1 { - /* color: green; */ - color: yellow; -} - -.physics-ans .user-answer-score-1 { - color: lightblue; -} - -td.user-answer-score-0::after { - content: "✗"; -} - -td.user-answer-score-1::after { - content: "✓"; -} - -td.user-answer-score-2::after { - content: "✓✓"; -} - -td.user-answer-score-1.rapid-ans::after { - content: "✓"; -} - -td.user-answer-score-2.rapid-ans::after { - content: "✓✓"; -} - -.score-row { - font-weight: bolder; - background-color: darkslateblue; -} - -.user-answer-score pre { - display: inline; - color: inherit; -} - -.correct-answer { - color: white; -} - -.user-answer-li { - padding-top: 10px; -} - -.parent-help:hover .help-text { - opacity: 50%; -} - -.parent-help .help-text { - opacity: 0%; -} - -.google-link { - padding-left: 10px; - font-size: smaller; -} - -.abcde { - list-style-type: lower-alpha; -} - -.abcde li { - padding: 0px; - clear: right; - vertical-align: text-top; -} - -#full-question-list-physics li { - padding-bottom: 10px; -} - - -#full-question-list-physics { - width: 800px; - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; -} - -.answer-1 { - /* color: green; */ - color: lightblue; -} - -.answer-0 { - color: red; -} - -.selector { - border-color: #05668d; -} - -.selector input { - color: gray; - background-color: white; - font-size: small; -} - -.selector .selector-available h2, -.selector .selector-chosen h2 { - margin-bottom: 0px; - border: none; - /* border-color: #05668d; */ - background-color: #ebf2fa; - color: #343a40; - font-size: larger; -} - -.selector .selector-available select, -.selector .selector-chosen select { - border-color: #05668d; -} - -#id_laterality { - margin-top: 30px; -} - -#id_laterality li { - display: inline-block; - border-radius: 4px; - text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); - background: rgb(28, 184, 65); - padding-right: 10px; - color: white; -} - -.add-popup { - display: block; -} - -.multi-image-block, -.series-block { - display: inline-flex; -} - -.sortable-placeholder { - border: 4px solid purple; - -} - -.temp-thumb { - float: right; - height: 100px; - width: 100px; -} - -.long-answer pre { - white-space: pre-wrap; - font: inherit; - color: inherit; -} - -.long-answer .answer-heading { - font-style: italic; - font-weight: bold; -} - -.proposed-answer { - color: darkblue -} - -.notes .complete { - color: darkslategray; -} - -table.longs .user-answer-score-4, -table.longs .user-answer-score-4\.0, -table.longs .user-answer-score-4\.5 { - color: red; -} - -table.longs .user-answer-score-5\.0, -table.longs .user-answer-score-5\.5 { - color: yellow; -} - -table.longs .user-answer-score-6\.0, -table.longs .user-answer-score-6\.5 { - color: yellowgreen; -} - -table.longs .user-answer-score-7\.0, -table.longs .user-answer-score-7\.5, -table.longs .user-answer-score-8\.0, -table.longs .user-answer-score-8, -table.longs .user-answer-score-8\.5 { - color: green; -} - -table.longs .user-answer-score- { - background-color: red; -} - -textarea, -input { - border-radius: 2px; - background-color: #343a40; - color: lightgray; -} - -.exam-list li, -#full-question-list li { - display: flex; -} - -.flex-col { - flex: 1; -} - -.flex-col-2 { - flex: 2; -} - -.flex-col-3 { - flex: 3; -} - -.flex-col-4 { - flex: 4; -} - -.score-answer-list { - width: 55%; -} - -.score-answer-list pre { - white-space: pre-wrap; -} - -.question-display-block { - position: fixed; - right: 0px; - top: 30px; - width: 40%; -} - -.view-question-link { - float: right; - color: blue; - cursor: pointer; - text-decoration: underline; -} - -.view-question-link-longs { - color: blue; - cursor: pointer; - text-decoration: underline; -} - -.date { - font-size: smaller; - opacity: 50%; -} - -.date:hover { - opacity: 100%; -} - -.floating-header { - position: sticky; - top: 1px; - background-color: black; - border: dashed 1px gray; - padding-left: 1em; - z-index: 999; -} - -.sba-answer-list { - list-style-type: lower-alpha; -} - -.sba-answer-list li:hover { - color: purple; -} - -#menu-list button { - padding-left: 2px; - padding-right: 2px; - padding-top: 0px; - padding-bottom: 0px; - margin: 1px; -} - -.sba-finish-list { - /* display: flex; */ -} - -/* .sba-finish-list li { - display: inline-block; - border: 1px solid purple; - min-width: 10px; -} -*/ - -.sba-finish-list a button.unanswered { - color: red; - border-style: dashed; -} - -.no-select { - user-select: none; -} - -.sba-answer-list .correct { - background-color: green; - color: white; -} - -.id { - opacity: 50%; - font-size: small; -} - -.sticky-alert { - position: sticky; - top: 5px; -} - -.marking-block { - display: inline-flex; - flex-wrap: wrap; -} - -.marking-block details { - padding-right: 20px; - flex: 1 -} - -.image-link { - color: purple; - text-decoration: underline; - cursor: pointer; -} - -.inner-display-block { - display: none; -} - -.hide-show { - float: right; - transform: rotate(90deg) translateX(10px); - color: blueviolet; - user-select: none; -} - -.clear-both { - clear: both; -} - -.check-review { - color: purple; -} - -.current-mark { - color: gray; -} - -.select2-dropdown { - background-color: #05668d; - color: white; - border-color: #05668d; -} - -.select2-selection .select2-selection--multiple, -.select2 .select2-selection--single, -.select2-selection { - background-color: #05668d; - color: white; - /* border: 1px solid #aaa; */ - border-radius: 0px; -} - -.select2-search__field { - background-color: #05668d; - color: darkblue; - border: 1px solid black; - -} - -.select2-container--default.select2-container--focus .select2-selection--multiple, -.select2-container--default.select2-container--default .select2-selection--multiple, -.select2-selection .select2-selection__rendered .select2-selection__choice { - background-color: #5897fb; -} - -.select2-selection__rendered { - user-select: none; -} - -.temp-thumb-large { - width: 1000px; - height: 1000px; - max-width: 1000px; -} - -.image-duplicate { - border: 2px solid orange; -} - -.finding-form { - border: 1px dashed darkblue; - -} - -.finding-box { - display: flex; - border: 1px dashed gray; -} - -.view-finding-button { - flex: 1; -} - -.view-finding-details { - flex: 4; -} - -.delete-finding-link, -.edit-finding-link { - flex: 1; -} - -summary h2, -summary h3, -summary h4, -summary h5 { - display: inline; -} - -.mark-buttons { - position: sticky; - bottom: 0px; - background-color: black; - opacity: 80%; +body { + color: gray; + background-color: black; +} + +a, +a:link { + color: #3282b8; +} + +.warn { + color: red; +} + +.answer-list { + font-size: 20px; + user-select: none; + -webkit-user-select: none; + /* Chrome/Safari */ + -moz-user-select: none; + /* Firefox */ + -ms-user-select: none; + /* IE10+ */ +} + +.answer-list .answer:hover { + z-index: 99999; + position: relative; + background: black; +} + +.answer-list .correct { + /* color: green; */ + color: lightblue; +} + +.answer-list .correct::after { + content: " [Score = 2]"; + font-size: small; +} + +.answer-list.rapid .correct::after { + content: " [Score = 2]"; +} + +.answer-list .half-correct { + color: yellow; +} + +.answer-list .half-correct::after { + content: " [Score = 1]"; + font-size: small; +} + +.answer-list.rapid .half-correct::after { + content: " [Score = 1]"; +} + +.answer-list .incorrect { + color: red; +} + +.answer-list .incorrect::after { + content: " [Score = 0]"; + font-size: small; +} + +.answer-list .not-marked { + color: white; +} + +.key { + font-size: 20; +} + +.hide { + display: none; +} + +.answered { + /* color: green; */ + color: lightblue; +} + +.unanswered { + color: gray; +} + +#question-list { + list-style: none; + float: right; + padding: 1px; + border: 1px solid gray; + /*max-height: 80%;*/ + height: 100%; + overflow-y: scroll; +} + +.flagged { + color: red; +} + +.not-flagged {} + +button { + text-decoration: none; + display: inline-block; + padding: 0.35em 1.2em; + border: 0.1em solid #52057b; + margin: 0 0.3em 0.3em 0; + border-radius: 0.12em; + box-sizing: border-box; + text-decoration: none; + font-family: 'Roboto', sans-serif; + font-weight: 300; + color: #a600ff; + text-align: center; + transition: all 0.2s; + background-color: transparent; +} + +button:hover { + color: white; + background-color: #52057b; +} + +button a { + text-decoration: none; + color: inherit; +} + +.main-nav-header { + /* max-width: 50%; */ + float: right; +} + +.main-nav-header span { + float: right; + padding-left: 10px; +} + +.top-bar-link {} + +.marking-list pre { + display: inline; +} + +.save { + display: inline-block; + padding: 0.35em 1.2em; + border: 0.1em solid #ffffff; + margin: 0 0.3em 0.3em 0; + border-radius: 0.12em; + box-sizing: border-box; + text-decoration: none; + font-family: "Roboto", sans-serif; + font-weight: 300; + color: #ffffff; + text-align: center; + transition: all 0.2s; + background-color: transparent; +} + +.save:hover { + color: #000000; + background-color: #ffffff; +} + +#stats-plot { + width: 50%; + float: right; +} + +#dicom-image { + width: 100%; + height: 600px; +} + +.dicom-viewer { + width: 100%; + height: 600px; +} + +.rapid-dicom-viewer { + width: 400px; + height: 600px; + position: fixed; + top: 30px; + right: 0px; + float: right; +} + +.marking-dicom { + position: fixed; + right: 0px; + bottom: 0px; + width: 45%; + height: 90%; + z-index: 1; +} + +.cornerstone-canvas { + height: 100%; +} + +.marking {} + +#question-mark-list { + padding: 20px +} + +#full-question-list { + counter-reset: question-list; + /* Set a counter named 'section', and its initial value is 0. */ +} + +#full-question-list li { + padding-bottom: 20px; +} + +#full-question-list li::before { + counter-increment: question-list; + content: counter(question-list) " "; + padding-right: 0.5em; +} + +#full-question-list img { + float: left; + padding-right: 20px; + padding-left: 10px; +} + +#question-mark-list a { + color: darkgray; +} + +#question-mark-list .unmarked a { + color: purple; +} + +#question-mark-list li { + padding-bottom: 5px; +} + +#start-marking-button { + float: right; + position: sticky; + top: 30px; +} + +#save-annotations { + position: absolute; + left: 0; +} + +.drop-target { + width: 100%; + width: 100vw; + height: 100px; + text-align: center; + font-size: larger; + position: sticky; + bottom: 0; + left: 0%; + right: 50%; + margin-left: -50vw; + margin-right: -50vw; +} + +#drop-container { + border: 1px dashed white; +} + +.submit-button { + position: sticky; + bottom: 0; +} + +#feedback-drop-target { + float: right; + height: 100%; + width: 30%; +} + +.drop-target-active { + background-color: #427aa1; +} + +#drop-filenames span { + font-size: xx-small; + color: gray; + padding-right: 1em; + +} + +.image-formset { + border: 1px solid white; +} + +select, +input { + background-color: #05668d; + color: white; + border: none; +} + +table.table { + color: white; +} + +#view-filter-options { + /* float: right; */ +} + +.rapid-img { + width: 300px; + height: 300px; +} + +.feedback-img { + border: 1px solid #05668d; +} + +.uploading { + margin-top: -20px; + height: 75%; + /* float: right; */ + position: absolute; + display: inline; + max-width: 100px; +} + +/* img.uploading:hover { + height: 95%; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) +} */ + +.image-ident-warning { + border: 2px solid red; +} + + +.image-ident-loading { + background: rgba(142, 68, 173, 1); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 1); + animation: pulse-purple 2s infinite; +} + +@keyframes pulse-purple { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(142, 68, 173, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(142, 68, 173, 0); + } +} + +#annotation-json-content { + display: none; +} + +.cid-score-table { + max-width: 400px; +} + +.user-answer-score-0 { + color: red; +} + +.user-answer-score-1 { + color: yellow; +} + +.user-answer-score-2 { + /* color: green; */ + color: lightblue; +} + +.rapid-ans.user-answer-score-2 { + color: lightblue; +} + +.rapid-ans.user-answer-score-1 { + /* color: green; */ + color: yellow; +} + +.physics-ans .user-answer-score-1 { + color: lightblue; +} + +td.user-answer-score-0::after { + content: "✗"; +} + +td.user-answer-score-1::after { + content: "✓"; +} + +td.user-answer-score-2::after { + content: "✓✓"; +} + +td.user-answer-score-1.rapid-ans::after { + content: "✓"; +} + +td.user-answer-score-2.rapid-ans::after { + content: "✓✓"; +} + +.score-row { + font-weight: bolder; + background-color: darkslateblue; +} + +.user-answer-score pre { + display: inline; + color: inherit; +} + +.correct-answer { + color: white; +} + +.user-answer-li { + padding-top: 10px; +} + +.parent-help:hover .help-text { + opacity: 50%; +} + +.parent-help .help-text { + opacity: 0%; +} + +.google-link { + padding-left: 10px; + font-size: smaller; +} + +.abcde { + list-style-type: lower-alpha; +} + +.abcde li { + padding: 0px; + clear: right; + vertical-align: text-top; +} + +#full-question-list-physics li { + padding-bottom: 10px; +} + + +#full-question-list-physics { + width: 800px; + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; +} + +.answer-1 { + /* color: green; */ + color: lightblue; +} + +.answer-0 { + color: red; +} + +.selector { + border-color: #05668d; +} + +.selector input { + color: gray; + background-color: white; + font-size: small; +} + +.selector .selector-available h2, +.selector .selector-chosen h2 { + margin-bottom: 0px; + border: none; + /* border-color: #05668d; */ + background-color: #ebf2fa; + color: #343a40; + font-size: larger; +} + +.selector .selector-available select, +.selector .selector-chosen select { + border-color: #05668d; +} + +#id_laterality { + margin-top: 30px; +} + +#id_laterality li { + display: inline-block; + border-radius: 4px; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); + background: rgb(28, 184, 65); + padding-right: 10px; + color: white; +} + +.add-popup { + display: block; +} + +.multi-image-block, +.series-block { + display: inline-flex; +} + +.sortable-placeholder { + border: 4px solid purple; + +} + +.temp-thumb { + float: right; + height: 100px; + width: 100px; +} + +.long-answer pre { + white-space: pre-wrap; + font: inherit; + color: inherit; +} + +.long-answer .answer-heading { + font-style: italic; + font-weight: bold; +} + +.proposed-answer { + color: darkblue +} + +.notes .complete { + color: darkslategray; +} + +table.longs .user-answer-score-4, +table.longs .user-answer-score-4\.0, +table.longs .user-answer-score-4\.5 { + color: red; +} + +table.longs .user-answer-score-5\.0, +table.longs .user-answer-score-5\.5 { + color: yellow; +} + +table.longs .user-answer-score-6\.0, +table.longs .user-answer-score-6\.5 { + color: yellowgreen; +} + +table.longs .user-answer-score-7\.0, +table.longs .user-answer-score-7\.5, +table.longs .user-answer-score-8\.0, +table.longs .user-answer-score-8, +table.longs .user-answer-score-8\.5 { + color: green; +} + +table.longs .user-answer-score- { + background-color: red; +} + +textarea, +input { + border-radius: 2px; + background-color: #343a40; + color: lightgray; +} + +.exam-list li, +#full-question-list li { + display: flex; +} + +.flex-col { + flex: 1; +} + +.flex-col-2 { + flex: 2; +} + +.flex-col-3 { + flex: 3; +} + +.flex-col-4 { + flex: 4; +} + +.score-answer-list { + width: 55%; +} + +.score-answer-list pre { + white-space: pre-wrap; +} + +.question-display-block { + position: fixed; + right: 0px; + top: 30px; + width: 40%; +} + +.view-question-link { + float: right; + color: blue; + cursor: pointer; + text-decoration: underline; +} + +.view-question-link-longs { + color: blue; + cursor: pointer; + text-decoration: underline; +} + +.date { + font-size: smaller; + opacity: 50%; +} + +.date:hover { + opacity: 100%; +} + +.floating-header { + position: sticky; + top: 1px; + background-color: black; + border: dashed 1px gray; + padding-left: 1em; + z-index: 999; +} + +.sba-answer-list { + list-style-type: lower-alpha; +} + +.sba-answer-list li:hover { + color: purple; +} + +#menu-list button { + padding-left: 2px; + padding-right: 2px; + padding-top: 0px; + padding-bottom: 0px; + margin: 1px; +} + +.sba-finish-list { + /* display: flex; */ +} + +/* .sba-finish-list li { + display: inline-block; + border: 1px solid purple; + min-width: 10px; +} +*/ + +.sba-finish-list a button.unanswered { + color: red; + border-style: dashed; +} + +.no-select { + user-select: none; +} + +.sba-answer-list .correct { + background-color: green; + color: white; +} + +.id { + opacity: 50%; + font-size: small; +} + +.sticky-alert { + position: sticky; + top: 5px; +} + +.marking-block { + display: inline-flex; + flex-wrap: wrap; +} + +.marking-block details { + padding-right: 20px; + flex: 1 +} + +.image-link { + color: purple; + text-decoration: underline; + cursor: pointer; +} + +.inner-display-block { + display: none; +} + +.hide-show { + float: right; + transform: rotate(90deg) translateX(10px); + color: blueviolet; + user-select: none; +} + +.clear-both { + clear: both; +} + +.check-review { + color: purple; +} + +.current-mark { + color: gray; +} + +.select2-dropdown { + background-color: #05668d; + color: white; + border-color: #05668d; +} + +.select2-selection .select2-selection--multiple, +.select2 .select2-selection--single, +.select2-selection { + background-color: #05668d; + color: white; + /* border: 1px solid #aaa; */ + border-radius: 0px; +} + +.select2-search__field { + background-color: #05668d; + color: darkblue; + border: 1px solid black; + +} + +.select2-container--default.select2-container--focus .select2-selection--multiple, +.select2-container--default.select2-container--default .select2-selection--multiple, +.select2-selection .select2-selection__rendered .select2-selection__choice { + background-color: #5897fb; +} + +.select2-selection__rendered { + user-select: none; +} + +.temp-thumb-large { + width: 1000px; + height: 1000px; + max-width: 1000px; +} + +.image-duplicate { + border: 2px solid orange; +} + +.finding-form { + border: 1px dashed darkblue; + +} + +.finding-box { + display: flex; + border: 1px dashed gray; +} + +.view-finding-button { + flex: 1; +} + +.view-finding-details { + flex: 4; +} + +.delete-finding-link, +.edit-finding-link { + flex: 1; +} + +summary h2, +summary h3, +summary h4, +summary h5 { + display: inline; +} + +.mark-buttons { + position: sticky; + bottom: 0px; + background-color: black; + opacity: 80%; } \ No newline at end of file diff --git a/templates/exam_overview_js.html b/templates/exam_overview_js.html index 75e787a3..45cc5b13 100644 --- a/templates/exam_overview_js.html +++ b/templates/exam_overview_js.html @@ -1,162 +1,162 @@ - \ No newline at end of file