diff --git a/atlas/models.py b/atlas/models.py index 00d704d5..78063dac 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -52,6 +52,7 @@ from generic.models import ( Examination, # Condition, ExamBase, + FindingBase, Plane, Contrast, QuestionNote, @@ -600,29 +601,18 @@ class SeriesImage(SeriesImageBase): # }} -class SeriesFinding(models.Model): +class SeriesFinding(FindingBase): 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) conditions = models.ManyToManyField(Condition, blank=True) - annotation_json = models.TextField(null=True, blank=True) - viewport_json = models.TextField(null=True, blank=True) - # This has been migrated to install the image id (not index) - current_image_id_index = models.TextField(null=True, blank=True) def __str__(self) -> str: findings = self.findings.all().values_list("name") return f"SeriesFinding: {self.series.id}/{findings}/{self.description}" - def annotation_as_string(self) -> str: - return json.dumps(self.annotation_json) - - @reversion.register class Series(SeriesBase): modality = models.ForeignKey( diff --git a/generic/models.py b/generic/models.py index 41a09206..c4ab7401 100644 --- a/generic/models.py +++ b/generic/models.py @@ -1963,4 +1963,32 @@ class CimarSeriesThumbnail(models.Model): image = models.ImageField(upload_to="cimar_series_thumbnails") def __str__(self): - return f"{self.series_id}" \ No newline at end of file + return f"{self.series_id}" + + +class FindingBase(models.Model): + description = models.TextField( + null=True, blank=True, help_text="Findings on the question" + ) + annotation_json = models.TextField(null=True, blank=True) + viewport_json = models.TextField(null=True, blank=True) + # This has been migrated to install the image id (not index) + current_image_id_index = models.TextField(null=True, blank=True) + + # TODO add tags + # tags = models.ManyToManyField(Tag, blank=True) + + class Meta: + abstract = True + + def __str__(self): + return self.description + + def annotation_as_string(self) -> str: + return json.dumps(self.annotation_json) + + def get_pretty_annotation_json(self): + return get_pretty_json(json.loads(self.annotation_json)) + + def get_pretty_viewport_json(self): + return get_pretty_json(json.loads(self.viewport_json)) \ No newline at end of file diff --git a/rad/api.py b/rad/api.py index 588f5486..d31ca66f 100644 --- a/rad/api.py +++ b/rad/api.py @@ -6,6 +6,7 @@ from anatomy.api import router as anatomy_router from sbas.api import router as sbas_router from physics.api import router as physics_router from atlas.api import router as atlas_router +from shorts.api import router as shorts_router api = NinjaAPI(version="1") @@ -17,6 +18,7 @@ api.add_router("longs/", longs_router) api.add_router("sbas/", sbas_router) api.add_router("physics/", physics_router) api.add_router("atlas/", atlas_router) +api.add_router("shorts/", shorts_router) @api.get("/hello") def hello(request): diff --git a/rapids/templates/rapids/question_display_block.html b/rapids/templates/rapids/question_display_block.html index d7f35d98..b9d5b272 100755 --- a/rapids/templates/rapids/question_display_block.html +++ b/rapids/templates/rapids/question_display_block.html @@ -66,8 +66,8 @@ {% endif %}> {{ answer }} - - {{answer.status}} + + {{answer.status}} {% endfor %} diff --git a/shorts/api.py b/shorts/api.py new file mode 100644 index 00000000..49736127 --- /dev/null +++ b/shorts/api.py @@ -0,0 +1,54 @@ +from typing import List +from django.shortcuts import get_object_or_404 +from ninja import ModelSchema, Router +from .models import Question, Exam + +from .decorators import user_is_author_or_shorts_checker +from django.core.exceptions import PermissionDenied + +from generic.decorators import check_user_in_group +from generic.constants import Group + +from ninja.security import django_auth + +router = Router() + + +class QuestionSchema(ModelSchema): + class Config: + model = Question + + #model_fields = ["question", "history", "feedback", "normal", "laterality"] + model_fields = "__all__" + + #model_exclude = ["answers"] + +class ExamSchema(ModelSchema): + class Config: + model = Exam + + model_fields = ["id", "name", "active", "publish_results"] + +@router.get('/') +def list_questions(request): + return [ + {"id": e.id, "normal": e.normal} + for e in Question.objects.all() + ] + +@router.get('/question/{question_id}', response=QuestionSchema) +@check_user_in_group(Group.cid_user_manager) +def get_rapid_details(request, question_id: int): + + rapid = get_object_or_404(Question, id=question_id) + return rapid + + +@router.get('/user_exams', response=List[ExamSchema], url_name="shorts_user_exams") +def user_exams(request): + """Returns a list of exams that the user has access to""" + user = request.user + if user.groups.filter(name="shorts_checker").exists(): + return Exam.objects.filter(archive=False).order_by('name') + + return Exam.objects.filter(author__id=user.id, archive=False).order_by('name') \ No newline at end of file diff --git a/shorts/forms.py b/shorts/forms.py index bc76ace3..bb6d3836 100644 --- a/shorts/forms.py +++ b/shorts/forms.py @@ -13,6 +13,7 @@ from shorts.models import ( Abnormality, Examination, Question, + QuestionFinding, QuestionImage, Region, SampleAnswer, @@ -29,10 +30,15 @@ from crispy_forms.layout import Submit, Layout, Div, Field, HTML from crispy_forms.bootstrap import InlineRadios -class QuestionAnswerForm(ModelForm): +from dal import autocomplete + +from autocomplete import widgets as htmx_widgets, Autocomplete, AutocompleteWidget, ModelAutocomplete, register as autocomplete_register + + +class QuestionSampleAnswerForm(ModelForm): class Meta: - model = UserAnswer - fields = ("answer",) + model = Question + fields = [] class MarkQuestionForm(Form): @@ -42,10 +48,10 @@ class MarkQuestionForm(Form): marked_answers = CharField(required=False) -class QuestionAnswerForm(ModelForm): - class Meta: - model = Question - fields = [] +#class QuestionAnswerForm(ModelForm): +# class Meta: +# model = Question +# fields = [] class QuestionForm(ModelForm): @@ -93,6 +99,7 @@ class QuestionForm(ModelForm): Field('examination', css_class='form-control'), Div(InlineRadios('laterality', css_class='form-control'), css_class='clearfix clear-both'), Field('history', css_class='form-control'), + Field('marking_guidance', css_class='form-control'), Field('feedback', css_class='form-control'), Field('open_access', css_class='form-control'), HTML("

Exams

"), @@ -188,6 +195,7 @@ class QuestionForm(ModelForm): # "site", "feedback", "history", + "marking_guidance", "open_access", ] # fields = ['question', 'feedback', 'subspecialty', 'references'] @@ -217,20 +225,20 @@ ImageFormSet = inlineformset_factory( # max_num=10, #) # -#AnswerUpdateFormSet = inlineformset_factory( -# Question, -# SampleAnswer, -# fields=["answer", "status"], -# widgets={ -# "answer": Textarea( -# attrs={"required": "false", "minlength": 2, "maxlength": 500, "rows": 3} -# ) -# }, -# exclude=[], -# can_delete=True, -# extra=0, -# max_num=10, -#) +SampleAnswerUpdateFormSet = inlineformset_factory( + Question, + SampleAnswer, + fields=["answer", "score", "proposed"], + 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? @@ -248,4 +256,31 @@ class ExamMarkerForm(ExamMarkerFormMixin): class ExamGroupsForm(ExamGroupsFormMixin): class Meta(ExamGroupsFormMixin.Meta): - model = Exam \ No newline at end of file + model = Exam + +class QuestionFindingForm(ModelForm): + class Meta: + model = QuestionFinding + exclude = ["question", "annotation_json", "viewport_json", "current_image_id_index"] + + widgets = { + "findings": autocomplete.ModelSelect2Multiple( + url="atlas:finding-autocomplete" + ), + "structures": autocomplete.ModelSelect2Multiple( + url="atlas:structure-autocomplete" + ), + "conditions": autocomplete.ModelSelect2Multiple( + url="atlas:condition-autocomplete" + ), + + } + + def __init__(self, *args, **kwargs): + if kwargs.get("question_id"): + initial = kwargs.setdefault("initial", {}) + initial["question"] = kwargs.pop("question_id") + + super(QuestionFindingForm, self).__init__(*args, **kwargs) + + ModelForm.__init__(self, *args, **kwargs) \ No newline at end of file diff --git a/shorts/migrations/0002_question_marking_guidance_alter_question_history.py b/shorts/migrations/0002_question_marking_guidance_alter_question_history.py new file mode 100644 index 00000000..e72933e4 --- /dev/null +++ b/shorts/migrations/0002_question_marking_guidance_alter_question_history.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.4 on 2025-04-07 08:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='marking_guidance', + field=models.TextField(blank=True, help_text='Marking guidance for the question. This is not shown to the candidate but is used by the marker to help them mark the question.', null=True), + ), + migrations.AlterField( + model_name='question', + name='history', + field=models.TextField(blank=True, help_text='Single line history for the question, e.g. Age 14, male. Referral from ED. History: Painful wrist after falling off skateboard.', null=True), + ), + ] diff --git a/shorts/migrations/0003_questionfinding.py b/shorts/migrations/0003_questionfinding.py new file mode 100644 index 00000000..cdb9cb3d --- /dev/null +++ b/shorts/migrations/0003_questionfinding.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.4 on 2025-04-07 09:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0067_case_cimar_uuid'), + ('shorts', '0002_question_marking_guidance_alter_question_history'), + ] + + operations = [ + migrations.CreateModel( + name='QuestionFinding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True, help_text='Findings on the question', null=True)), + ('annotation_json', models.TextField(blank=True, null=True)), + ('viewport_json', models.TextField(blank=True, null=True)), + ('current_image_id_index', models.TextField(blank=True, null=True)), + ('conditions', models.ManyToManyField(blank=True, to='atlas.condition')), + ('findings', models.ManyToManyField(blank=True, to='atlas.finding')), + ('question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='findings', to='shorts.question')), + ('structures', models.ManyToManyField(blank=True, to='atlas.structure')), + ], + ), + ] diff --git a/shorts/migrations/0004_remove_questionimage_image_annotations.py b/shorts/migrations/0004_remove_questionimage_image_annotations.py new file mode 100644 index 00000000..24f67525 --- /dev/null +++ b/shorts/migrations/0004_remove_questionimage_image_annotations.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.4 on 2025-04-07 10:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0003_questionfinding'), + ] + + operations = [ + migrations.RemoveField( + model_name='questionimage', + name='image_annotations', + ), + ] diff --git a/shorts/models.py b/shorts/models.py index ac229044..768aa324 100644 --- a/shorts/models.py +++ b/shorts/models.py @@ -1,14 +1,28 @@ from collections import defaultdict +import os from django.db import models from django.urls import reverse from django.conf import settings +import pydicom +from atlas.models import Finding, Structure, Condition from rad.settings import REMOTE_URL from django.utils.translation import gettext_lazy as _ import dicognito import json # Create your models here. -from generic.models import CidUser, CidUserGroup, ExamBase, ExamCollection, ExamUserStatus, QuestionBase, UserAnswerBase, UserUserGroup +from generic.models import ( + CidUser, + CidUserGroup, + ExamBase, + ExamCollection, + ExamUserStatus, + FindingBase, + QuestionBase, + UserAnswerBase, + UserUserGroup, + get_pretty_json, +) from django.contrib.contenttypes.fields import GenericRelation from django.core.validators import MaxValueValidator, MinValueValidator from django.utils.html import mark_safe @@ -17,16 +31,20 @@ from rapids.models import Abnormality, Examination, Region from helpers.images import get_image_hash, image_as_base64 + def image_directory_path(instance, filename): # return u"{0}".format(filename) return "shorts/picture/{0}".format(filename) -class Question(QuestionBase): - """ - """ - history = models.TextField(null=True, blank=True, - help_text="Single line history for the question, e.g. Age 14, male. Referral from ED. History: Painful wrist after falling off skateboard.") +class Question(QuestionBase): + """ """ + + history = models.TextField( + null=True, + blank=True, + help_text="Single line history for the question, e.g. Age 14, male. Referral from ED. History: Painful wrist after falling off skateboard.", + ) NONE = "NONE" LEFT = "LEFT" @@ -60,6 +78,12 @@ class Question(QuestionBase): help_text="Applies to the answer, not the examination", ) + marking_guidance = models.TextField( + null=True, + blank=True, + help_text="Marking guidance for the question. This is not shown to the candidate but is used by the marker to help them mark the question.", + ) + author = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, @@ -70,6 +94,9 @@ class Question(QuestionBase): def get_app_name(self): return "shorts" + def get_findings_url(self): + return reverse("shorts:question_findings", kwargs={"question_id": self.pk}) + def get_absolute_url(self): return reverse("shorts:question_detail", kwargs={"pk": self.pk}) @@ -92,10 +119,10 @@ class Question(QuestionBase): # def GetNonFeedbackQuestionImages(self): # return self.get_images() - def get_image_annotations(self): - return json.dumps( - [i.image_annotations for i in self.images.all() if not i.feedback_image] - ) + #def get_image_annotations(self): + # return json.dumps( + # [i.image_annotations for i in self.images.all() if not i.feedback_image] + # ) def get_laterality_string(self): if self.laterality == self.NONE: @@ -122,15 +149,31 @@ class Question(QuestionBase): except pydicom.errors.InvalidDicomError: pass + def check_user_can_edit(self, user): + if user.is_superuser: + return True + + if self.author.filter(id=user.id).exists(): + return True + + if user.groups.filter(name="shorts_checker").exists(): + return True + + return False + + class QuestionImage(models.Model): - question = models.ForeignKey(Question, related_name="images", on_delete=models.CASCADE) + question = models.ForeignKey( + Question, related_name="images", on_delete=models.CASCADE + ) image = models.FileField(upload_to=image_directory_path) - image_annotations = models.TextField( - blank=True, - null=True, - help_text="Stores a JSON representation of annotations to be applied by cornerstonetools", - ) + # Image annotations are stored as findings in shorts cases + #image_annotations = models.TextField( + # blank=True, + # null=True, + # help_text="Stores a JSON representation of annotations to be applied by cornerstonetools", + #) feedback_image = models.BooleanField(default=False) @@ -162,7 +205,9 @@ class QuestionImage(models.Model): def save(self, *args, **kwargs): """Override save method to add image hash""" if self.image: - image_hash, is_dicom = get_image_hash(self.image, hash_type="md5", direct_pixel_data=False) + image_hash, is_dicom = get_image_hash( + self.image, hash_type="md5", direct_pixel_data=False + ) self.is_dicom = is_dicom self.image_md5_hash = image_hash @@ -170,19 +215,29 @@ class QuestionImage(models.Model): if image_hash != "12345ABCD": super().save(*args, **kwargs) # Call the "real" save() method. + class SampleAnswer(models.Model): + """Model that defines sample answers for the question. + + These are used to aid marking and for feedback purposes + + """ + question = models.ForeignKey( Question, related_name="sample_answers", on_delete=models.CASCADE ) answer = models.TextField() - score = models.IntegerField(default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)]) + score = models.IntegerField( + default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)] + ) proposed = models.BooleanField(default=False) def __str__(self): return self.answer + class ExamQuestionDetail(models.Model): exam = models.ForeignKey("Exam", on_delete=models.CASCADE) question = models.ForeignKey(Question, on_delete=models.CASCADE) @@ -192,10 +247,13 @@ class ExamQuestionDetail(models.Model): class Meta: ordering = ("sort_order",) + class Exam(ExamBase): app_name = "shorts" - exam_questions = models.ManyToManyField(Question, through=ExamQuestionDetail, related_name="exams") + exam_questions = models.ManyToManyField( + Question, through=ExamQuestionDetail, related_name="exams" + ) time_limit = models.IntegerField( help_text="Exam time limit (in seconds). Default is 2 hours", @@ -238,11 +296,20 @@ class Exam(ExamBase): related_name="shorts_user_user_groups", ) - exam_user_status = GenericRelation(ExamUserStatus, related_query_name="shorts_exams") - cid_user_exam = GenericRelation("generic.CidUserExam", related_query_name="shorts_exams") - - examcollection = models.ForeignKey(ExamCollection, blank=True, null=True, on_delete=models.SET_NULL, related_name="shorts_exams") + exam_user_status = GenericRelation( + ExamUserStatus, related_query_name="shorts_exams" + ) + cid_user_exam = GenericRelation( + "generic.CidUserExam", related_query_name="shorts_exams" + ) + examcollection = models.ForeignKey( + ExamCollection, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="shorts_exams", + ) def get_app_name(self): return "shorts" @@ -325,6 +392,7 @@ class Exam(ExamBase): def get_absolute_url(self): return reverse("shorts:exam_overview", kwargs={"pk": self.pk}) + class UserAnswer(UserAnswerBase): """User answers by candidate""" @@ -349,7 +417,9 @@ class UserAnswer(UserAnswerBase): Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True ) - score = models.IntegerField(default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)]) + score = models.IntegerField( + default=0, blank=True, validators=[MinValueValidator(0), MaxValueValidator(5)] + ) class CallStateOptions(models.TextChoices): CORRECT = "C", _("Correct Call") @@ -398,4 +468,18 @@ class UserAnswer(UserAnswerBase): return self.callstate.label def get_answer_score(self, cached=True): - return self.score \ No newline at end of file + return self.score + + +class QuestionFinding(FindingBase): + question = models.ForeignKey( + Question, related_name="findings", on_delete=models.SET_NULL, null=True + ) + findings = models.ManyToManyField(Finding, blank=True) + structures = models.ManyToManyField(Structure, blank=True) + conditions = models.ManyToManyField(Condition, blank=True) + + + def __str__(self) -> str: + findings = self.findings.all().values_list("name") + return f"Findings: {self.question.id}/{findings}/{self.description}" diff --git a/shorts/templates/shorts/question_display_block.html b/shorts/templates/shorts/question_display_block.html index 3fc87db2..fc3eb33a 100755 --- a/shorts/templates/shorts/question_display_block.html +++ b/shorts/templates/shorts/question_display_block.html @@ -26,6 +26,7 @@ {% endfor %} + Click here to view/add findings
Exam(s): {% for exam in question.exams.all %} {{ exam }}, @@ -40,9 +41,28 @@

Feedback: {{ question.feedback }}

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

-

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

+

+ + Sample Answers: + + + + {% for answer in question.sample_answers.all|dictsortreversed:"score" %} + + + + + {% endfor %} +
AnswerScore
+ + {{ answer }} + + {{answer.score}}
+
+

+{% comment %}

Answers: @@ -62,49 +82,15 @@ {% endfor %}
-

+

{% endcomment %}
{% if view_feedback %} {% include 'question_notes.html' %} - {% if not question.normal %} -
- - Suggested answers - - -
- {% endif %} {% endif %}
-
- - Image viewer - -
-
- -
- -
- - Image annotations: - - {% for image in question.images.all %} - - Image {{ forloop.counter }}: {{image.image_annotations}}
-
- {% endfor %} -
-Anonymise dicoms
+ +{% endblock js %} \ No newline at end of file diff --git a/shorts/templates/shorts/question_link_header.html b/shorts/templates/shorts/question_link_header.html index b3081d93..9212d353 100644 --- a/shorts/templates/shorts/question_link_header.html +++ b/shorts/templates/shorts/question_link_header.html @@ -1,10 +1,11 @@
- + View Edit Clone Delete - Edit Answers + Edit Sample Answers + Edit Findings Add Note {% if request.user.is_superuser %} Admin Edit diff --git a/shorts/templates/shorts/sample_answer_form.html b/shorts/templates/shorts/sample_answer_form.html new file mode 100755 index 00000000..4622fb33 --- /dev/null +++ b/shorts/templates/shorts/sample_answer_form.html @@ -0,0 +1,78 @@ +{% extends "shorts/base.html" %} +{% load static from static %} + +{% block js %} + + + + +{{ form.media }} +{% endblock %} +{% block content %} + +{% if object %} + {% include "shorts/question_link_header.html" %} +{% endif %} + +

Update Sample Answers

+

This page allows you to update sample answers for the question.

+ +

Sample answers are used to aid marking of a question and providing feedback to candidates.

+ +

Ideally there should be a selection of answers that cover the available scores

+ + +
+ {% csrf_token %} +

Answers:

+
+ {% for form in answer_formset %} +
    + {{form.non_field_errors}} + {{form.errors}} + {{ form.as_ul }} +
+ {% endfor %} +
+ {{ answer_formset.management_form }} + +

+ +

+
+ + +{% endblock %} \ No newline at end of file diff --git a/shorts/urls.py b/shorts/urls.py index b3ad0c22..3f22c4ec 100644 --- a/shorts/urls.py +++ b/shorts/urls.py @@ -82,10 +82,15 @@ urlpatterns = [ name="question_update", ), path( - "question//update_answers", - views.QuestionAnswerUpdate.as_view(), - name="question_answer_update", + "question//sample_answers", + views.QuestionSampleAnswerUpdate.as_view(), + name="question_sample_answers", ), + #path( + # "question//update_answers", + # views.QuestionAnswerUpdate.as_view(), + # name="question_answer_update", + #), path( "user_answers/", views.UserAnswerTableView.as_view(), @@ -99,6 +104,27 @@ urlpatterns = [ views.UserAnswerDelete.as_view(), name="user_answer_delete", ), + path( + "question//findings", + views.question_findings, + name="question_findings", + ), + + path( + "question/add_finding", + views.create_findings, + name="add_finding", + ), + path( + "question/delete_finding/", + views.FindingDelete.as_view(), + name="delete_finding", + ), + path( + "question//", + views.question_findings, + name="question_edit_finding", + ), #path( # "answer-autocomplete", # views.AnswerAutocomplete.as_view( diff --git a/shorts/views.py b/shorts/views.py index d7fc9375..419ff3ac 100644 --- a/shorts/views.py +++ b/shorts/views.py @@ -37,11 +37,13 @@ from generic.views import ( UpdateQuestionMixin, ) from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin +from atlas.models import Finding, Structure, Condition from shorts.decorators import user_is_author_or_shorts_checker from .models import ( Exam, Question, + QuestionFinding, QuestionImage, UserAnswer, SampleAnswer, @@ -53,11 +55,13 @@ from .forms import ( ExamAuthorForm, ExamGroupsForm, ExamMarkerForm, + QuestionFindingForm, QuestionForm, - QuestionAnswerForm, + QuestionSampleAnswerForm, MarkQuestionForm, ExamForm, ImageFormSet, + SampleAnswerUpdateFormSet, ) @@ -224,18 +228,41 @@ class QuestionCreate(QuestionCreateBase): # return super().form_valid(form) -class QuestionAnswerUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView): +class QuestionSampleAnswerUpdate(AuthorOrCheckerRequiredMixin, UpdateView): model = Question - form_class = QuestionAnswerForm - template_name = "shorts/shortsquestionanswer_form.html" + form_class = QuestionSampleAnswerForm + template_name = "shorts/sample_answer_form.html" def get_context_data(self, **kwargs): - context = super(QuestionAnswerUpdate, self).get_context_data(**kwargs) + context = super(QuestionSampleAnswerUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["answer_formset"] = SampleAnswerUpdateFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["answer_formset"].full_clean() + else: + context["answer_formset"] = SampleAnswerUpdateFormSet(instance=self.object) context["question"] = context["object"] return context def form_valid(self, form): - return super().form_valid(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"] + # logger.debug(formset.is_valid()) + if formset.is_valid(): + response = super().form_valid(form) + formset.instance = self.object + formset.save() + + return response + else: + return super().form_invalid(form) class QuestionUpdate( @@ -728,4 +755,98 @@ def question_add_exam(request, question_id: int): return HttpResponse(mark_safe(html)) - raise PermissionDenied() \ No newline at end of file + raise PermissionDenied() + + +# At the moment only the series owner / author can delete findings but anyone can create +# TODO: duplicated from atlas "create_series_findings" +def create_findings(request): + # posts = Post.objects.all() + response_data = {} + + if request.POST.get("action") == "post": + question_finding_id = int(request.POST.get("question_finding_id")) + question_id = request.POST.get("question") + findings_ids = json.loads(request.POST.get("findings")) + structure_ids = json.loads(request.POST.get("structures")) + condition_ids = json.loads(request.POST.get("conditions")) + description = request.POST.get("description") + annotation_json = request.POST.get("annotation_json") + viewport_json = request.POST.get("viewport_json") + current_image_id_index = request.POST.get("current_image_id_index") + + question = Question.objects.get(pk=question_id) + findings = Finding.objects.filter(pk__in=findings_ids) + structures = Structure.objects.filter(pk__in=structure_ids) + conditions = Condition.objects.filter(pk__in=condition_ids) + + if question_finding_id > 0: + sf = get_object_or_404(QuestionFinding, pk=question_finding_id) + sf.question = question + sf.description = description + sf.annotation_json = annotation_json + sf.viewport_json = viewport_json + else: + sf = QuestionFinding.objects.create( + question=question, + description=description, + annotation_json=annotation_json, + viewport_json=viewport_json, + ) + + sf.findings.set(findings) + sf.structures.set(structures) + sf.conditions.set(conditions) + sf.current_image_id_index = current_image_id_index + sf.save() + return JsonResponse({"success": True}) + + return JsonResponse({"success": False}) + return render(request, "create_post.html", {"posts": posts}) + +# TODO fix permissions +class FindingDelete(RevisionMixin, DeleteView): # PermissionRequiredMixin, + #permission_required = "atlas.delete_seriesfinding" + model = QuestionFinding + template_name = "confirm_delete.html" + # success_url = reverse_lazy("atlas:case_view") + + def get_object(self): + question_finding = super().get_object() + question = question_finding.question + if question.check_user_can_edit(self.request.user): + return question_finding + else: + raise PermissionDenied + + def get_success_url(self): + pk = self.kwargs["pk"] + question_id = get_object_or_404(QuestionFinding, pk=pk).question.id + return reverse("shorts:question_detail", kwargs={"pk": question_id}) + +def question_findings(request, question_id, finding_pk=None): + question = get_object_or_404(Question, pk=question_id) + + #can_edit = series.check_user_can_edit(request.user) + can_edit = True + + editing_finding = -1 + if finding_pk is not None: + finding = get_object_or_404(QuestionFinding, pk=finding_pk) + question_finding_form = QuestionFindingForm(None, instance=finding) + editing_finding = finding.pk + else: + question_finding_form = QuestionFindingForm(question_id=question.id) + + # logging.debug(atlas.subspecialty.first().name.all()) + return render( + request, + "shorts/question_findings.html", + { + "question": question, + "question_finding_form": question_finding_form, + "editing_finding": editing_finding, + "can_edit": can_edit, + }, + ) +