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.
-
-
- Show all Show unmarked
-
-
-
-
-
+{% extends 'anatomy/exams.html' %}
+
+{% block content %}
+
+
Marking exam: {{ exam.name }}
+ You can start marking from a particular question by clicking on it below.
+
+
+ Show all Show unmarked
+
+
+
+
+
{% 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 }}
+
+
+ {% 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 %}
-
-
- {% 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}}
+
+
+
+
+
+
+{% 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 %}
-Add finding
-{% endif %}
-Reset viewport
-
-
-
- Findings
-
- {% for finding in series.findings.all %}
-
-
Click to view
-
- 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.
-
-
- Show all Show unmarked
-
-
-
-
-
+{% extends 'rapids/exams.html' %}
+
+{% block content %}
+
+
Marking exam: {{ exam.name }}
+ You can start marking from a particular question by clicking on it below.
+
+
+ Show all Show unmarked
+
+
+
+
+
{% 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
-
-{% endblock %}
+{% extends "sbas/base.html" %}
+
+{% block js %}
+ {{ form.media }}
+{% endblock %}
+
+{% block content %}
+Add Exam
+
+{% 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