From 80059d375e02290e9d9ceecb2d1b9853aed859d5 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 Jan 2021 10:01:39 +0000 Subject: [PATCH 01/32] start rapid app --- rapids/__init__.py | 0 rapids/admin.py | 3 +++ rapids/apps.py | 5 +++++ rapids/migrations/__init__.py | 0 rapids/models.py | 3 +++ rapids/tests.py | 3 +++ rapids/views.py | 3 +++ 7 files changed, 17 insertions(+) create mode 100644 rapids/__init__.py create mode 100644 rapids/admin.py create mode 100644 rapids/apps.py create mode 100644 rapids/migrations/__init__.py create mode 100644 rapids/models.py create mode 100644 rapids/tests.py create mode 100644 rapids/views.py diff --git a/rapids/__init__.py b/rapids/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rapids/admin.py b/rapids/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/rapids/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/rapids/apps.py b/rapids/apps.py new file mode 100644 index 00000000..4f99b06e --- /dev/null +++ b/rapids/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class RapidsConfig(AppConfig): + name = 'rapids' diff --git a/rapids/migrations/__init__.py b/rapids/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rapids/models.py b/rapids/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/rapids/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/rapids/tests.py b/rapids/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/rapids/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/rapids/views.py b/rapids/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/rapids/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 508934f94c5a0bfb2b72dea73f28f5b4e6b12d10 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 Jan 2021 11:18:07 +0000 Subject: [PATCH 02/32] merge rapids commit --- rad/urls.py | 2 +- rapids/__init__.py | 0 rapids/admin.py | 57 +++ rapids/apps.py | 0 rapids/decorators.py | 13 + rapids/filters.py | 10 + rapids/forms.py | 102 ++++ rapids/models.py | 388 ++++++++++++++- rapids/tables.py | 26 + rapids/templates/admin/base_site.html | 5 + rapids/templates/rapids/author_list.html | 8 + rapids/templates/rapids/base.html | 54 +++ rapids/templates/rapids/category_detail.html | 13 + rapids/templates/rapids/create_simple.html | 5 + rapids/templates/rapids/index.html | 23 + rapids/templates/rapids/note_form.html | 12 + rapids/templates/rapids/rapid_detail.html | 11 + .../templates/rapids/rapid_display_block.html | 32 ++ rapids/templates/rapids/rapid_form.html | 251 ++++++++++ .../rapids/rapidcreationdefault_form.html | 10 + rapids/templates/rapids/view.html | 18 + rapids/tests.py | 0 rapids/urls.py | 46 ++ rapids/views.py | 451 +++++++++++++++++- 24 files changed, 1533 insertions(+), 4 deletions(-) mode change 100644 => 100755 rapids/__init__.py mode change 100644 => 100755 rapids/admin.py mode change 100644 => 100755 rapids/apps.py create mode 100755 rapids/decorators.py create mode 100755 rapids/filters.py create mode 100755 rapids/forms.py create mode 100755 rapids/tables.py create mode 100755 rapids/templates/admin/base_site.html create mode 100755 rapids/templates/rapids/author_list.html create mode 100755 rapids/templates/rapids/base.html create mode 100755 rapids/templates/rapids/category_detail.html create mode 100755 rapids/templates/rapids/create_simple.html create mode 100755 rapids/templates/rapids/index.html create mode 100755 rapids/templates/rapids/note_form.html create mode 100755 rapids/templates/rapids/rapid_detail.html create mode 100755 rapids/templates/rapids/rapid_display_block.html create mode 100755 rapids/templates/rapids/rapid_form.html create mode 100755 rapids/templates/rapids/rapidcreationdefault_form.html create mode 100755 rapids/templates/rapids/view.html mode change 100644 => 100755 rapids/tests.py create mode 100755 rapids/urls.py mode change 100644 => 100755 rapids/views.py diff --git a/rad/urls.py b/rad/urls.py index 83b113f2..8b520e69 100644 --- a/rad/urls.py +++ b/rad/urls.py @@ -27,7 +27,7 @@ urlpatterns = [ path("anatomy/", include("anatomy.urls"), name="anatomy"), path("physics/", include("physics.urls"), name="physics"), #path("sbas/", include("sbas.urls"), name="sbas"), - #path("rapids/", include("rapids.urls"), name="rapids"), + path("rapids/", include("rapids.urls"), name="rapids"), path("accounts/", include("django.contrib.auth.urls")), path("accounts/profile", views.profile, name="profile"), path("", TemplateView.as_view(template_name="index.html"), name="home"), diff --git a/rapids/__init__.py b/rapids/__init__.py old mode 100644 new mode 100755 diff --git a/rapids/admin.py b/rapids/admin.py old mode 100644 new mode 100755 index 8c38f3f3..2a2cd8b5 --- a/rapids/admin.py +++ b/rapids/admin.py @@ -1,3 +1,60 @@ from django.contrib import admin +from .models import Rapid, RapidImage, Examination, Site, Abnormality, Region, Note, RapidCreationDefault +import tagulous.admin + +from django.forms import ModelForm + +from reversion.admin import VersionAdmin +from django.forms.widgets import RadioSelect # Register your models here. +admin.site.register(Examination) +admin.site.register(Site) +admin.site.register(Abnormality) +admin.site.register(Region) +admin.site.register(Note) +admin.site.register(RapidCreationDefault) + + +class RapidImageInline(admin.TabularInline): + model = RapidImage + extra = 1 + readonly_fields = [ + "image_tag", + ] + + +class RapidAdminForm(ModelForm): + class Meta: + model = Rapid + + fields = [ + "normal", "abnormality", "region", "laterality", "examination", + "condition", "sign", "site", "feedback", "author" + ] + + widgets = { + "laterality": RadioSelect(attrs={'style': 'display: inline-flex'}), + } + + +class RapidAdmin(VersionAdmin): + form = RapidAdminForm + + filter_horizontal = ( + "abnormality", + "region", + "examination", + ) + save_on_top = True + save_as = True + view_on_site = True + + inlines = [ + RapidImageInline, + ] + + +admin.site.register(Rapid, RapidAdmin) + +#tagulous.admin.register(Rapid.condition) diff --git a/rapids/apps.py b/rapids/apps.py old mode 100644 new mode 100755 diff --git a/rapids/decorators.py b/rapids/decorators.py new file mode 100755 index 00000000..72c1c36b --- /dev/null +++ b/rapids/decorators.py @@ -0,0 +1,13 @@ +from django.core.exceptions import PermissionDenied +from .models import Rapid + +def user_is_author_or_rapid_checker(function): + def wrap(request, *args, **kwargs): + rapid = Rapid.objects.get(pk=kwargs['pk']) + if request.user in rapid.author.all() or request.user.groups.filter(name='rapid_checker').exists(): + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap diff --git a/rapids/filters.py b/rapids/filters.py new file mode 100755 index 00000000..3f10b4f6 --- /dev/null +++ b/rapids/filters.py @@ -0,0 +1,10 @@ +import django_filters + +from .models import Rapid + + +class RapidFilter(django_filters.FilterSet): + class Meta: + model = Rapid + fields = ("normal", "abnormality", "region", "examination", + "laterality", "site", "created_date", "author") diff --git a/rapids/forms.py b/rapids/forms.py new file mode 100755 index 00000000..85c8278a --- /dev/null +++ b/rapids/forms.py @@ -0,0 +1,102 @@ +from django.forms import ModelForm, ModelMultipleChoiceField, ModelChoiceField, ChoiceField +from django.forms import inlineformset_factory + +from rapids.models import Abnormality, Examination, Note, Rapid, RapidCreationDefault, RapidImage, Region + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms.widgets import RadioSelect + + +class ExaminationForm(ModelForm): + class Meta: + model = Examination + fields = ['examination'] + + +class NoteForm(ModelForm): + class Meta: + model = Note + fields = ['note'] + + +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): + 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): + 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=True, + widget=RadioSelect()) + + class Meta: + model = Rapid + #fields = ['due_back'] + #fields = '__all__' + fields = [ + "normal", "abnormality", "region", "laterality", "examination", + "condition", "sign", "site", "feedback" + ] + #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=4, + max_num=10, + field_classes="testing") diff --git a/rapids/models.py b/rapids/models.py index 71a83623..ddd997d0 100644 --- a/rapids/models.py +++ b/rapids/models.py @@ -1,3 +1,389 @@ from django.db import models +from django.utils import timezone -# Create your models here. +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 sortedm2m.fields import SortedManyToManyField + +import string + +image_storage = FileSystemStorage( + # Physical file location ROOT + location=u"{0}/rapids/".format(settings.MEDIA_ROOT), + # Url for file + base_url=u"{0}rapids/".format(settings.MEDIA_URL), +) + +def image_directory_path(instance, filename): + return u"picture/{0}".format(filename) + + +class AnswerStrings(models.Model): + text = models.CharField(max_length=255, unique=True) + + def __str__(self): + return self.text + + +class Abnormality(models.Model): + name = models.CharField(max_length=200, + unique=True, + help_text="Primary abnormality on the film(s)") + + def __str__(self): + return self.name + + class Meta: + ordering = ('name', ) + + +class Region(models.Model): + name = models.CharField( + max_length=200, + unique=True, + help_text="Region of the abnormality (not including lateralitiy)", + blank=True) + + def __str__(self): + return self.name + + class Meta: + ordering = ('name', ) + + +class Examination(models.Model): + examination = models.CharField(max_length=200) + + 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 Answer(models.Model): + question = models.ForeignKey( + Rapid, related_name="answers", on_delete=models.CASCADE + ) + answer = models.TextField(max_length=500) + answer_compare = models.TextField(max_length=500) + + class MarkOptions(models.TextChoices): + UNMARKED = "", _("Unmarked") + INCORRECT = "0", _("Incorrect") + HALF_MARK = "1", _("Half mark") + CORRECT = "2", _("Correct") + + status = models.CharField( + max_length=1, choices=MarkOptions.choices, default=MarkOptions.UNMARKED + ) + + def __str__(self): + return self.answer + + def save(self, *args, **kwargs): + self.clean() + return super(Answer, self).save(*args, **kwargs) + + def clean(self): + if self.answer: + self.answer = self.answer.strip() + + s = self.answer.lower() + s = s.translate(str.maketrans('', '', string.punctuation)) + + self.answer_compare = s + + +class Rapid(models.Model): + #author = models.ForeignKey('auth.User', on_delete=models.CASCADE) + #image = models.ImageField() + question = models.TextField(null=True, blank=True) + feedback = models.TextField(null=True, blank=True) + + normal = models.BooleanField(default=False, help_text="Tick if true") + + NONE = "NONE" + LEFT = "LEFT" + RIGHT = "RIGHT" + BILATERAL = "BILAT" + + LATERALITY_CHOICES = ( + (NONE, "None"), + (LEFT, "Left"), + (RIGHT, "Right"), + (BILATERAL, "Bilateral"), + ) + + abnormality = models.ManyToManyField( + Abnormality, + blank=True, + help_text="The abnormality (laterality and region independent)", + ) + region = models.ManyToManyField( + Region, + blank=True, + help_text="Region of the abnormality (laterality independent)") + examination = models.ManyToManyField( + Examination, help_text="Name of the (primary) examination") + laterality = models.CharField( + max_length=20, + choices=LATERALITY_CHOICES, + default=NONE, + help_text="Applies to the answer, not the examination") + condition = tagulous.models.TagField( + to=Condition, + blank=True, + help_text= + "Associated condition. Will allow searching / filtering and tips / hints to be displayed. Conditions with spaces must be enclosed in quotes \"...\"" + ) + + sign = tagulous.models.TagField( + to=Sign, blank=True, help_text='Radiological signs in the question') + + DEFAULT_SITE_ID = 1 + site = models.ManyToManyField( + Site, + blank=True, + default=DEFAULT_SITE_ID, + help_text= + "If we know the source of the image (should possibly be removed?)") + 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 question', + related_name="rapid_authored_questions") + + scrapped = models.BooleanField( + default=False, + help_text='Question has been scrapped and will not be shown') + + def get_absolute_url(self): + return reverse('rapids:rapid_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_regions(self): + """Returns a comma seperated text list of regions""" + regions = ", ".join([i.name for i in self.region.all()]) + return regions + + def get_abnormalities(self): + """Returns a comma seperated text list of regions""" + abnormalities = ", ".join([i.name for i in self.abnormality.all()]) + return abnormalities + + def __str__(self): + n = "Normal" + if not self.normal: + #n = self.answers.first() + n = "{} / {}".format(self.abnormality.first(), self.region.first()) + exams = ", ".join([str(i) for i in self.examination.all()]) + lat = "" + if self.laterality != "NONE": + lat = self.laterality + return "{} / {} {}".format(exams, lat, n) + + +class RapidImage(models.Model): + rapid = models.ForeignKey(Rapid, + related_name="images", + on_delete=models.CASCADE) + image = models.ImageField(upload_to=image_directory_path, storage=image_storage) + + feedback_image = models.BooleanField(default=False) + + def image_tag(self): + if self.image: + return mark_safe( + 'Click and hold to zoom' + .format(settings.MEDIA_URL, self.image)) + else: + return "" + + image_tag.short_description = 'Image' + + +class Note(models.Model): + rapid = models.ForeignKey(Rapid, + related_name="rapid_notes", + on_delete=models.CASCADE, + null=True) + author = models.ForeignKey(User, + related_name="rapid_notes", + on_delete=models.CASCADE) + note = models.TextField() + created_on = models.DateTimeField(auto_now_add=True) + + def get_absolute_url(self): + self.pk = self.rapid_id + return reverse('rapids:rapid_detail', kwargs={'pk': self.pk}) + + def __str__(self): + return "{} [{}] {}".format(self.rapid, self.author, self.created_on) + + +class RapidCreationDefault(models.Model): + author = models.OneToOneField(User, + related_name="rapid_default", + on_delete=models.CASCADE) + + site = models.ManyToManyField( + Site, + blank=True, + default=1, + help_text="Default site to use when creating a new rapid") + + def get_absolute_url(self): + return reverse('rapids:rapid_create') + + +class AnatomyQuestion(models.Model): + # author = models.ForeignKey('auth.User', on_delete=models.CASCADE) + # image = models.ImageField() + # feedback = models.TextField(null=True) + question_type = models.ForeignKey( + QuestionType, on_delete=models.SET_NULL, null=True + ) + + image = models.ImageField(upload_to=image_directory_path, storage=image_storage) + + image_annotations = models.TextField( + blank=True, + help_text="Stores a JSON representation of annotations to be applied by cornerstonetools", + ) + + description = models.CharField( + max_length=400, + help_text="Short description of the image e.g. 'Sagittal CT Chest, Abdomen & Pelvis', will be displayed as the title.", + ) + + examination = models.ForeignKey( + Examination, on_delete=models.SET_NULL, null=True, blank=True + ) + modality = models.ForeignKey( + Modality, + on_delete=models.SET_NULL, + null=True, + help_text="Modality of the image", + ) + region = models.ForeignKey( + Region, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Region the image covers", + ) + body_part = models.ForeignKey( + BodyPart, on_delete=models.SET_NULL, null=True, blank=True + ) + structure = models.ForeignKey( + Structure, on_delete=models.SET_NULL, null=True, blank=True + ) + created_date = models.DateTimeField(default=timezone.now) + + open_access = models.BooleanField( + help_text="If an question should be freely available to browse", default=True + ) + + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + help_text="Author(s) of question", + related_name="anatomy_authored_questions", + ) + + def __str__(self): + # Get first answer + return "{} [{}, {}]".format( + self.GetPrimaryAnswer(), self.modality, self.structure + ) + # Get associated exams + e = self.exams.all().values_list("name", flat=True) + exams = ", ".join(e) + + if a is None: + return "({})".format(exams) + else: + return "{} ({}) [{}, {}]".format( + a.answer, exams, self.modality, self.structure + ) + + def get_absolute_url(self): + return reverse("anatomy:question_detail", kwargs={"pk": self.pk}) + + def GetPrimaryAnswer(self): + if len(self.answers.all()) > 0: + return self.answers.all().first().answer + else: + return "None yet..." + + def GetExams(self): + e = self.exams.all().values_list("name", flat=True) + exams = ", ".join(e) + return exams + + def GetUnmarkedAnswersString(self): + unmarked_answers = self.GetUnmarkedAnswers() + + if not unmarked_answers: + return "No answers to mark" + return format_html( + "{} answer unmarked: {}".format( + len(unmarked_answers), ", ".join(unmarked_answers) + ) + ) + + def GetUnmarkedAnswers(self): + user_answers = set( + [i.answer_compare for i in self.cid_user_answers.all()] + ) + unmarked_answers = user_answers - self.GetMarkedAnswers() + + return unmarked_answers + + def GetUnmarkedAnswerCount(self): + return len(self.GetUnmarkedAnswers()) + + def GetMarkedAnswers(self): + return set( + [ + i.answer_compare + for i in self.answers.all() + if i.status != i.MarkOptions.UNMARKED + ] + ) + correct_answers = set( + [i.answer.get_compare_string() for i in self.answers.all()] + ) + half_mark_answers = set( + [i.answer.get_compare_string() for i in self.half_mark_answers.all()] + ) + incorrect_answers = set( + [i.answer.get_compare_string() for i in self.incorrect_answers.all()] + ) + + marked_answers = correct_answers | half_mark_answers | incorrect_answers + return marked_answers \ No newline at end of file diff --git a/rapids/tables.py b/rapids/tables.py new file mode 100755 index 00000000..a11c49ce --- /dev/null +++ b/rapids/tables.py @@ -0,0 +1,26 @@ +import django_tables2 as tables +from django_tables2.utils import A + +from .models import Rapid + + +class RapidTable(tables.Table): + edit = tables.LinkColumn('rapids:rapid_update', + text='Edit', + args=[A('pk')], + orderable=False) + view = tables.LinkColumn('rapids:rapid_detail', + text='View', + args=[A('pk')], + orderable=False) + clone = tables.LinkColumn('rapids:rapid_clone', + text='Clone', + args=[A('pk')], + orderable=False) + + class Meta: + model = Rapid + template_name = "django_tables2/bootstrap4.html" + fields = ("normal", "abnormality", "region", "examination", + "laterality", "site", "created_date", "author") + sequence = ("view", ) \ No newline at end of file diff --git a/rapids/templates/admin/base_site.html b/rapids/templates/admin/base_site.html new file mode 100755 index 00000000..792249f6 --- /dev/null +++ b/rapids/templates/admin/base_site.html @@ -0,0 +1,5 @@ +{% extends "admin/base_site.html" %} +{% load static %} +{% block extrahead %} + +{% endblock %} \ No newline at end of file diff --git a/rapids/templates/rapids/author_list.html b/rapids/templates/rapids/author_list.html new file mode 100755 index 00000000..82564136 --- /dev/null +++ b/rapids/templates/rapids/author_list.html @@ -0,0 +1,8 @@ +{% extends 'rapids/base.html' %} + +{% block content %} + +{% for author in authors %} +

Author: {{author.username}},

+{% endfor %} +{% endblock %} \ No newline at end of file diff --git a/rapids/templates/rapids/base.html b/rapids/templates/rapids/base.html new file mode 100755 index 00000000..e762e551 --- /dev/null +++ b/rapids/templates/rapids/base.html @@ -0,0 +1,54 @@ +{% load static %} + + + + + {% block css %} + {% endblock %} + + + + + + + + + + + + + {% block js %} + {% endblock %} + + + + + Rapids + + + +
+ {% block navigation %} + {% endblock %} +
+
+ {% block content %} + {% endblock %} +
+
+
+ + + \ No newline at end of file diff --git a/rapids/templates/rapids/category_detail.html b/rapids/templates/rapids/category_detail.html new file mode 100755 index 00000000..0d8e840e --- /dev/null +++ b/rapids/templates/rapids/category_detail.html @@ -0,0 +1,13 @@ +{% extends 'rapids/base.html' %} + +{% block content %} +{{category}}: + + +{% endblock %} \ No newline at end of file diff --git a/rapids/templates/rapids/create_simple.html b/rapids/templates/rapids/create_simple.html new file mode 100755 index 00000000..14e906af --- /dev/null +++ b/rapids/templates/rapids/create_simple.html @@ -0,0 +1,5 @@ +

Add new {{name}}

+
{% csrf_token %} + {{ form.as_p }} + +
\ No newline at end of file diff --git a/rapids/templates/rapids/index.html b/rapids/templates/rapids/index.html new file mode 100755 index 00000000..8bc76cd7 --- /dev/null +++ b/rapids/templates/rapids/index.html @@ -0,0 +1,23 @@ +{% extends 'rapids/base.html' %} + +{% block content %} +My Questions: + + +Other Questions: + + +{% endblock %} \ No newline at end of file diff --git a/rapids/templates/rapids/note_form.html b/rapids/templates/rapids/note_form.html new file mode 100755 index 00000000..89dcd1ae --- /dev/null +++ b/rapids/templates/rapids/note_form.html @@ -0,0 +1,12 @@ +{% extends "rapids/base.html" %} + +{% block content %} +

Add Note

+
+ {% csrf_token %} + + {{ form.as_table }} +
+ +
+{% endblock %} diff --git a/rapids/templates/rapids/rapid_detail.html b/rapids/templates/rapids/rapid_detail.html new file mode 100755 index 00000000..ffb52dc9 --- /dev/null +++ b/rapids/templates/rapids/rapid_detail.html @@ -0,0 +1,11 @@ +{% extends 'rapids/base.html' %} + +{% block content %} +Edit +Clone + Add Note +{% if request.user.is_superuser %} +Admin Edit +{% endif %} +{% include 'rapids/rapid_display_block.html' %} +{% endblock %} \ No newline at end of file diff --git a/rapids/templates/rapids/rapid_display_block.html b/rapids/templates/rapids/rapid_display_block.html new file mode 100755 index 00000000..3f0f2b58 --- /dev/null +++ b/rapids/templates/rapids/rapid_display_block.html @@ -0,0 +1,32 @@ +
+
+ {{ rapid.created_date }} +
+ +

Rapid: {{ rapid }}

+

Region: {{ rapid.get_regions }}

+

Abnormality: {{ rapid.get_abnormalities }}

+

Images: + {% for image in rapid.images.all %} + Image {{ forloop.counter }}{% if image.feedback_image %} [feedback image]{% endif %}: + + {% endfor %}

+

Feedback: {{ rapid.feedback }}

+

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

+

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

+

Scrapped: {{ rapid.scrapped }} (toggle) +

+
+
+ Notes: +
    + {% for note in rapid.rapid_notes.all %} +
  • + {{ note.created_on }} by {{ note.author }}: {{ note.note }} +
  • + {% endfor %} +
+ +
\ No newline at end of file diff --git a/rapids/templates/rapids/rapid_form.html b/rapids/templates/rapids/rapid_form.html new file mode 100755 index 00000000..73097adb --- /dev/null +++ b/rapids/templates/rapids/rapid_form.html @@ -0,0 +1,251 @@ +{% extends "rapids/base.html" %} +{% load static from staticfiles %} + +{% block js %} + + + + +{{ form.media }} +{% endblock %} +{% block content %} +

Submit Rapid

+Edit defaults +
+ {% csrf_token %} + + + + + + {{ form.as_table }} +
+

Images:

+ +
Drop images here (or use the buttons below)
Feedback image?
drop those here
+
+
+
+ {% for form in image_formset %} +
    + {{form.non_field_errors}} + {{form.errors}} + {{ form.as_ul }} +
+ {% endfor %} +
+ {{ image_formset.management_form }} + + +
+ +{% endblock %} \ No newline at end of file diff --git a/rapids/templates/rapids/rapidcreationdefault_form.html b/rapids/templates/rapids/rapidcreationdefault_form.html new file mode 100755 index 00000000..d5ec635d --- /dev/null +++ b/rapids/templates/rapids/rapidcreationdefault_form.html @@ -0,0 +1,10 @@ +{% extends "rapids/base.html" %} + + +{% block content %} +

Defaults for creating new rapids

+
{% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} \ No newline at end of file diff --git a/rapids/templates/rapids/view.html b/rapids/templates/rapids/view.html new file mode 100755 index 00000000..2d6dddd3 --- /dev/null +++ b/rapids/templates/rapids/view.html @@ -0,0 +1,18 @@ +{% extends 'rapids/base.html' %} + +{% load render_table from django_tables2 %} +{% block css %} +{% endblock %} + +{% block content %} + +
+

Filter Rapids

+
+ {{ filter.form }} + +
+
+{% render_table table %} + +{% endblock %} \ No newline at end of file diff --git a/rapids/tests.py b/rapids/tests.py old mode 100644 new mode 100755 diff --git a/rapids/urls.py b/rapids/urls.py new file mode 100755 index 00000000..ea47966a --- /dev/null +++ b/rapids/urls.py @@ -0,0 +1,46 @@ +from django.urls import path, include +from . import views + +app_name = "rapids" + +urlpatterns = [ + # path('', views.question_list, name='question_list'), + path("", views.index, name="index"), + path("author//", views.author_detail, name="author_detail"), + path("author/", views.author_list, name="author_list"), + #path("unchecked/", views.unchecked_list, name="unchecked_list"), + #path("verified//", views.verified_detail, name="verified_detail"), + path("/", views.rapid_detail, name="rapid_detail"), + path("/split", views.rapid_split, name="rapid_split"), + path("/clone", views.RapidClone.as_view(), name="rapid_clone"), + #path("verified/", views.verified, name="verified"), + #path("all_questions/", views.all_questions, name="all_questions"), + path("/scrap", views.rapid_scrap, name="rapid_scrap"), + path("create/", views.RapidCreate.as_view(), name="rapid_create"), + path("create/defaults", + views.RapidCreationDefaultView.as_view(), + name="rapid_create_defaults"), + path("view/", views.RapidView.as_view(), name="rapid_view"), + path("region/create/", views.create_region, name="create_region"), + path("region/ajax/get_region_id", + views.get_region_id, + name="get_region_id"), + path("abnormality/create/", + views.create_abnormality, + name="create_abnormality"), + path("abnormality/ajax/get_abnormality_id", + views.get_abnormality_id, + name="get_abnormality_id"), + path("examination/create/", + views.create_examination, + name="create_examination"), + path("examination/ajax/get_examination_id", + views.get_examination_id, + name="get_examination_id"), + path( + "/update", + views.RapidUpdate.as_view(), + name="rapid_update", + ), + path("/add_note", views.AddNote.as_view(), name="rapid_add_note"), +] diff --git a/rapids/views.py b/rapids/views.py old mode 100644 new mode 100755 index 91ea44a2..c4d94676 --- a/rapids/views.py +++ b/rapids/views.py @@ -1,3 +1,450 @@ -from django.shortcuts import render +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 -# Create your views here. +from django.contrib.auth.mixins import LoginRequiredMixin + +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, ImageFormSet, NoteForm, RegionForm, AbnormalityForm, ExaminationForm +from .models import Rapid, Note, Abnormality, Region, Examination +from .tables import RapidTable +from .filters import RapidFilter + +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 logging +from copy import deepcopy +from django.forms.models import model_to_dict +from rapids.forms import RapidCreationDefaultForm +from rapids.models import RapidCreationDefault +logger = logging.getLogger(__name__) + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super(UpdateView, self).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 index(request): + other_rapids = Rapid.objects.exclude(author=request.user.pk) + user_rapids = Rapid.objects.filter(author=request.user.pk) + return render(request, "rapids/index.html", { + "other_rapids": other_rapids, + "user_rapids": user_rapids + }) + + +@login_required +def rapid_detail(request, pk): + rapid = get_object_or_404(Rapid, pk=pk) + + #if request.user not in rapid.author.all(): + # raise PermissionDenied + + #logging.debug(rapid.rapid_notes.first()) + #logging.debug(rapid.subspecialty.first().name.all()) + return render(request, 'rapids/rapid_detail.html', {'rapid': rapid}) + + +@login_required +def rapid_split(request, pk): + rapid = get_object_or_404(Rapid, pk=pk) + + 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.rapid_notes.first()) + #logging.debug(rapid.subspecialty.first().name.all()) + return render(request, 'rapids/rapid_detail.html', {'rapid': rapid}) + + +@login_required +def author_detail(request, pk): + #logging.debug(Author.objects.all()) + #author = get_object_or_404(Author, pk=pk) + author = User.objects.get(pk=pk) + + rapids = Rapid.objects.filter(author=pk) + + return render(request, "rapids/category_detail.html", { + "category": author, + "rapids": rapids + }) + + +def author_list(request): + authors = User.objects.all() + + return render(request, 'rapids/author_list.html', {'authors': authors}) + + +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) + + +class AddNote(LoginRequiredMixin, CreateView): + model = Note + form_class = NoteForm + + # 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 form_valid(self, form): + + note = form.save(commit=False) + + note.rapid = self.pk + + note.author = self.request.user + note.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) + + formset = ImageFormSet() + + if form.is_valid(): + form.instance.author.add(request.user.id) + + #logger.debug(formset.is_valid()) + if formset.is_valid(): + response = super().form_valid(form) + formset.instance = obj + formset.save() + return response + else: + return super().form_invalid(form) + + context = { + "form": form, + "image_formset": formset + #other context + } + + return render(request, "rapids/rapid_form.html", context) + + +class RapidCreateBase(LoginRequiredMixin, CreateView): + model = Rapid + form_class = RapidForm + + 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) + else: + context['image_formset'] = ImageFormSet() + 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['image_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('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): + # 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(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_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() + else: + context['image_formset'] = ImageFormSet(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) + formset = context['image_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 RapidClone(RapidCreateBase): + """Clones a existing rapid""" + + # 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(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") + + rapid.scrapped = not rapid.scrapped + rapid.save() + return HttpResponseRedirect(reverse('rapids:rapid_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(SingleTableMixin, FilterView): + model = Rapid + table_class = RapidTable + template_name = "rapids/view.html" + + filterset_class = RapidFilter \ No newline at end of file From 542e559ed12a7b147e508dcfe6707c266375d30f Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 Jan 2021 13:40:28 +0000 Subject: [PATCH 03/32] more rapid merging/fixing --- anatomy/migrations/0019_auto_20210118_1339.py | 20 ++ rad/settings.py | 1 + rapids/admin.py | 4 +- rapids/forms.py | 2 +- rapids/migrations/0001_initial.py | 122 ++++++++++ rapids/models.py | 213 ++++++------------ rapids/templates/rapids/base.html | 4 +- 7 files changed, 216 insertions(+), 150 deletions(-) create mode 100644 anatomy/migrations/0019_auto_20210118_1339.py create mode 100644 rapids/migrations/0001_initial.py diff --git a/anatomy/migrations/0019_auto_20210118_1339.py b/anatomy/migrations/0019_auto_20210118_1339.py new file mode 100644 index 00000000..779db910 --- /dev/null +++ b/anatomy/migrations/0019_auto_20210118_1339.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.2 on 2021-01-18 13:39 + +import anatomy.models +import django.core.files.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0018_auto_20210112_1726'), + ] + + operations = [ + migrations.AlterField( + model_name='anatomyquestion', + name='image', + field=models.ImageField(storage=django.core.files.storage.FileSystemStorage(base_url='/media/anatomy/', location='/home/ross/sites/rad/media//anatomy/'), upload_to=anatomy.models.image_directory_path), + ), + ] diff --git a/rad/settings.py b/rad/settings.py index 9b4aff1b..bb1647df 100644 --- a/rad/settings.py +++ b/rad/settings.py @@ -32,6 +32,7 @@ ALLOWED_HOSTS = ["localhost", "127.0.0.1", "161.35.163.87", "penracourses.org.uk INSTALLED_APPS = [ 'anatomy', 'physics', + 'rapids', "reversion", "django_tables2", "django_filters", diff --git a/rapids/admin.py b/rapids/admin.py index 2a2cd8b5..a00dba5e 100755 --- a/rapids/admin.py +++ b/rapids/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from .models import Rapid, RapidImage, Examination, Site, Abnormality, Region, Note, RapidCreationDefault -import tagulous.admin +#import tagulous.admin from django.forms import ModelForm @@ -30,7 +30,7 @@ class RapidAdminForm(ModelForm): fields = [ "normal", "abnormality", "region", "laterality", "examination", - "condition", "sign", "site", "feedback", "author" + "site", "feedback", "author" ] widgets = { diff --git a/rapids/forms.py b/rapids/forms.py index 85c8278a..afcc2863 100755 --- a/rapids/forms.py +++ b/rapids/forms.py @@ -82,7 +82,7 @@ class RapidForm(ModelForm): #fields = '__all__' fields = [ "normal", "abnormality", "region", "laterality", "examination", - "condition", "sign", "site", "feedback" + "site", "feedback" ] #fields = ['question', 'feedback', 'subspecialty', 'references'] widgets = { diff --git a/rapids/migrations/0001_initial.py b/rapids/migrations/0001_initial.py new file mode 100644 index 00000000..6f3b1cc7 --- /dev/null +++ b/rapids/migrations/0001_initial.py @@ -0,0 +1,122 @@ +# Generated by Django 3.1.2 on 2021-01-18 13:39 + +from django.conf import settings +import django.core.files.storage +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import rapids.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Abnormality', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Primary abnormality on the film(s)', max_length=200, unique=True)), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='Examination', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('examination', models.CharField(max_length=200)), + ], + options={ + 'ordering': ('examination',), + }, + ), + migrations.CreateModel( + name='Rapid', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', models.TextField(blank=True, null=True)), + ('feedback', models.TextField(blank=True, null=True)), + ('normal', models.BooleanField(default=False, help_text='Tick if true')), + ('laterality', models.CharField(choices=[('NONE', 'None'), ('LEFT', 'Left'), ('RIGHT', 'Right'), ('BILAT', 'Bilateral')], default='NONE', help_text='Applies to the answer, not the examination', max_length=20)), + ('verified', models.BooleanField(default=False)), + ('created_date', models.DateTimeField(default=django.utils.timezone.now)), + ('published_date', models.DateTimeField(blank=True, null=True)), + ('scrapped', models.BooleanField(default=False, help_text='Question has been scrapped and will not be shown')), + ('abnormality', models.ManyToManyField(blank=True, help_text='The abnormality (laterality and region independent)', to='rapids.Abnormality')), + ('author', models.ManyToManyField(blank=True, help_text='Author of question', related_name='rapid_authored_questions', to=settings.AUTH_USER_MODEL)), + ('examination', models.ManyToManyField(help_text='Name of the (primary) examination', to='rapids.Examination')), + ], + ), + migrations.CreateModel( + name='Region', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, help_text='Region of the abnormality (not including lateralitiy)', max_length=200, unique=True)), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='Site', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('site', models.CharField(max_length=200)), + ('initials', models.CharField(max_length=200)), + ], + ), + migrations.CreateModel( + name='RapidImage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(storage=django.core.files.storage.FileSystemStorage(base_url='/media/rapids/', location='/home/ross/sites/rad/media//rapids/'), upload_to=rapids.models.image_directory_path)), + ('feedback_image', models.BooleanField(default=False)), + ('rapid', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='rapids.rapid')), + ], + ), + migrations.CreateModel( + name='RapidCreationDefault', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('author', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='rapid_default', to=settings.AUTH_USER_MODEL)), + ('site', models.ManyToManyField(blank=True, default=1, help_text='Default site to use when creating a new rapid', to='rapids.Site')), + ], + ), + migrations.AddField( + model_name='rapid', + name='region', + field=models.ManyToManyField(blank=True, help_text='Region of the abnormality (laterality independent)', to='rapids.Region'), + ), + migrations.AddField( + model_name='rapid', + name='site', + field=models.ManyToManyField(blank=True, default=1, help_text='If we know the source of the image (should possibly be removed?)', to='rapids.Site'), + ), + migrations.CreateModel( + name='Note', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('note', models.TextField()), + ('created_on', models.DateTimeField(auto_now_add=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rapid_notes', to=settings.AUTH_USER_MODEL)), + ('rapid', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rapid_notes', to='rapids.rapid')), + ], + ), + migrations.CreateModel( + name='Answer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answer', models.TextField(max_length=500)), + ('answer_compare', models.TextField(max_length=500)), + ('status', models.CharField(choices=[('', 'Unmarked'), ('0', 'Incorrect'), ('1', 'Half mark'), ('2', 'Correct')], default='', max_length=1)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='rapids.rapid')), + ], + ), + ] diff --git a/rapids/models.py b/rapids/models.py index ddd997d0..0a07d1da 100644 --- a/rapids/models.py +++ b/rapids/models.py @@ -24,12 +24,6 @@ def image_directory_path(instance, filename): return u"picture/{0}".format(filename) -class AnswerStrings(models.Model): - text = models.CharField(max_length=255, unique=True) - - def __str__(self): - return self.text - class Abnormality(models.Model): name = models.CharField(max_length=200, @@ -80,7 +74,7 @@ class Site(models.Model): class Answer(models.Model): question = models.ForeignKey( - Rapid, related_name="answers", on_delete=models.CASCADE + "Rapid", related_name="answers", on_delete=models.CASCADE ) answer = models.TextField(max_length=500) answer_compare = models.TextField(max_length=500) @@ -148,15 +142,15 @@ class Rapid(models.Model): choices=LATERALITY_CHOICES, default=NONE, help_text="Applies to the answer, not the examination") - condition = tagulous.models.TagField( - to=Condition, - blank=True, - help_text= - "Associated condition. Will allow searching / filtering and tips / hints to be displayed. Conditions with spaces must be enclosed in quotes \"...\"" - ) - - sign = tagulous.models.TagField( - to=Sign, blank=True, help_text='Radiological signs in the question') +# condition = tagulous.models.TagField( +# to=Condition, +# blank=True, +# help_text= +# "Associated condition. Will allow searching / filtering and tips / hints to be displayed. Conditions with spaces must be enclosed in quotes \"...\"" +# ) +# +# sign = tagulous.models.TagField( +# to=Sign, blank=True, help_text='Radiological signs in the question') DEFAULT_SITE_ID = 1 site = models.ManyToManyField( @@ -206,134 +200,6 @@ class Rapid(models.Model): lat = self.laterality return "{} / {} {}".format(exams, lat, n) - -class RapidImage(models.Model): - rapid = models.ForeignKey(Rapid, - related_name="images", - on_delete=models.CASCADE) - image = models.ImageField(upload_to=image_directory_path, storage=image_storage) - - feedback_image = models.BooleanField(default=False) - - def image_tag(self): - if self.image: - return mark_safe( - 'Click and hold to zoom' - .format(settings.MEDIA_URL, self.image)) - else: - return "" - - image_tag.short_description = 'Image' - - -class Note(models.Model): - rapid = models.ForeignKey(Rapid, - related_name="rapid_notes", - on_delete=models.CASCADE, - null=True) - author = models.ForeignKey(User, - related_name="rapid_notes", - on_delete=models.CASCADE) - note = models.TextField() - created_on = models.DateTimeField(auto_now_add=True) - - def get_absolute_url(self): - self.pk = self.rapid_id - return reverse('rapids:rapid_detail', kwargs={'pk': self.pk}) - - def __str__(self): - return "{} [{}] {}".format(self.rapid, self.author, self.created_on) - - -class RapidCreationDefault(models.Model): - author = models.OneToOneField(User, - related_name="rapid_default", - on_delete=models.CASCADE) - - site = models.ManyToManyField( - Site, - blank=True, - default=1, - help_text="Default site to use when creating a new rapid") - - def get_absolute_url(self): - return reverse('rapids:rapid_create') - - -class AnatomyQuestion(models.Model): - # author = models.ForeignKey('auth.User', on_delete=models.CASCADE) - # image = models.ImageField() - # feedback = models.TextField(null=True) - question_type = models.ForeignKey( - QuestionType, on_delete=models.SET_NULL, null=True - ) - - image = models.ImageField(upload_to=image_directory_path, storage=image_storage) - - image_annotations = models.TextField( - blank=True, - help_text="Stores a JSON representation of annotations to be applied by cornerstonetools", - ) - - description = models.CharField( - max_length=400, - help_text="Short description of the image e.g. 'Sagittal CT Chest, Abdomen & Pelvis', will be displayed as the title.", - ) - - examination = models.ForeignKey( - Examination, on_delete=models.SET_NULL, null=True, blank=True - ) - modality = models.ForeignKey( - Modality, - on_delete=models.SET_NULL, - null=True, - help_text="Modality of the image", - ) - region = models.ForeignKey( - Region, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Region the image covers", - ) - body_part = models.ForeignKey( - BodyPart, on_delete=models.SET_NULL, null=True, blank=True - ) - structure = models.ForeignKey( - Structure, on_delete=models.SET_NULL, null=True, blank=True - ) - created_date = models.DateTimeField(default=timezone.now) - - open_access = models.BooleanField( - help_text="If an question should be freely available to browse", default=True - ) - - author = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - help_text="Author(s) of question", - related_name="anatomy_authored_questions", - ) - - def __str__(self): - # Get first answer - return "{} [{}, {}]".format( - self.GetPrimaryAnswer(), self.modality, self.structure - ) - # Get associated exams - e = self.exams.all().values_list("name", flat=True) - exams = ", ".join(e) - - if a is None: - return "({})".format(exams) - else: - return "{} ({}) [{}, {}]".format( - a.answer, exams, self.modality, self.structure - ) - - def get_absolute_url(self): - return reverse("anatomy:question_detail", kwargs={"pk": self.pk}) - def GetPrimaryAnswer(self): if len(self.answers.all()) > 0: return self.answers.all().first().answer @@ -386,4 +252,61 @@ class AnatomyQuestion(models.Model): ) marked_answers = correct_answers | half_mark_answers | incorrect_answers - return marked_answers \ No newline at end of file + return marked_answers + + +class RapidImage(models.Model): + rapid = models.ForeignKey(Rapid, + related_name="images", + on_delete=models.CASCADE) + image = models.ImageField(upload_to=image_directory_path, storage=image_storage) + + feedback_image = models.BooleanField(default=False) + + def image_tag(self): + if self.image: + return mark_safe( + 'Click and hold to zoom' + .format(settings.MEDIA_URL, self.image)) + else: + return "" + + image_tag.short_description = 'Image' + + +class Note(models.Model): + rapid = models.ForeignKey(Rapid, + related_name="rapid_notes", + on_delete=models.CASCADE, + null=True) + #author = models.ForeignKey(User, + author = models.ForeignKey( settings.AUTH_USER_MODEL, + related_name="rapid_notes", + on_delete=models.CASCADE) + note = models.TextField() + created_on = models.DateTimeField(auto_now_add=True) + + def get_absolute_url(self): + self.pk = self.rapid_id + return reverse('rapids:rapid_detail', kwargs={'pk': self.pk}) + + def __str__(self): + return "{} [{}] {}".format(self.rapid, self.author, self.created_on) + + +class RapidCreationDefault(models.Model): + #author = models.OneToOneField(User, + author = models.OneToOneField( + settings.AUTH_USER_MODEL, + related_name="rapid_default", + on_delete=models.CASCADE) + + site = models.ManyToManyField( + Site, + blank=True, + default=1, + help_text="Default site to use when creating a new rapid") + + def get_absolute_url(self): + return reverse('rapids:rapid_create') + diff --git a/rapids/templates/rapids/base.html b/rapids/templates/rapids/base.html index e762e551..e8ad97c7 100755 --- a/rapids/templates/rapids/base.html +++ b/rapids/templates/rapids/base.html @@ -9,10 +9,10 @@ - + {% comment %} - + {% endcomment %} From 558bd69ee5da761573867034ab92fadc709544d7 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 Jan 2021 14:00:37 +0000 Subject: [PATCH 04/32] more fixes --- anatomy/forms.py | 2 - rad/settings.py | 2 +- rapids/admin.py | 15 ++++++- rapids/forms.py | 34 +++++++++++++- rapids/models.py | 112 ++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 158 insertions(+), 7 deletions(-) diff --git a/anatomy/forms.py b/anatomy/forms.py index f57e961a..35672a64 100644 --- a/anatomy/forms.py +++ b/anatomy/forms.py @@ -156,7 +156,6 @@ AnswerFormSet = inlineformset_factory( can_delete=True, extra=1, max_num=10, - field_classes="testing", ) AnswerUpdateFormSet = inlineformset_factory( @@ -172,7 +171,6 @@ AnswerUpdateFormSet = inlineformset_factory( can_delete=True, extra=0, max_num=10, - field_classes="testing", ) diff --git a/rad/settings.py b/rad/settings.py index bb1647df..591ffbbe 100644 --- a/rad/settings.py +++ b/rad/settings.py @@ -23,7 +23,7 @@ with open('/etc/secret_key') as f: SECRET_KEY = f.read().strip() # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = False +DEBUG = True ALLOWED_HOSTS = ["localhost", "127.0.0.1", "161.35.163.87", "penracourses.org.uk"] diff --git a/rapids/admin.py b/rapids/admin.py index a00dba5e..0a0f6d14 100755 --- a/rapids/admin.py +++ b/rapids/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin -from .models import Rapid, RapidImage, Examination, Site, Abnormality, Region, Note, RapidCreationDefault +from .models import Rapid, RapidImage, Examination, Site, Abnormality, Region, Note, RapidCreationDefault, Answer, Exam + #import tagulous.admin from django.forms import ModelForm @@ -10,10 +11,16 @@ from django.forms.widgets import RadioSelect # Register your models here. admin.site.register(Examination) admin.site.register(Site) +admin.site.register(Exam) admin.site.register(Abnormality) admin.site.register(Region) admin.site.register(Note) admin.site.register(RapidCreationDefault) +admin.site.register(Answer) + +class RapidAnswersInline(admin.TabularInline): + model = Answer + extra = 1 class RapidImageInline(admin.TabularInline): @@ -23,6 +30,10 @@ class RapidImageInline(admin.TabularInline): "image_tag", ] +class ExamInline(admin.TabularInline): + model = Rapid.exams.through + extra = 1 + class RapidAdminForm(ModelForm): class Meta: @@ -52,6 +63,8 @@ class RapidAdmin(VersionAdmin): inlines = [ RapidImageInline, + RapidAnswersInline, + ExamInline, ] diff --git a/rapids/forms.py b/rapids/forms.py index afcc2863..73f15940 100755 --- a/rapids/forms.py +++ b/rapids/forms.py @@ -1,10 +1,10 @@ from django.forms import ModelForm, ModelMultipleChoiceField, ModelChoiceField, ChoiceField from django.forms import inlineformset_factory -from rapids.models import Abnormality, Examination, Note, Rapid, RapidCreationDefault, RapidImage, Region +from rapids.models import Abnormality, Examination, Note, Rapid, RapidCreationDefault, RapidImage, Region, Answer from django.contrib.admin.widgets import FilteredSelectMultiple -from django.forms.widgets import RadioSelect +from django.forms.widgets import RadioSelect, TextInput, Textarea class ExaminationForm(ModelForm): @@ -100,3 +100,33 @@ ImageFormSet = inlineformset_factory(Rapid, extra=4, max_num=10, field_classes="testing") + +AnswerFormSet = inlineformset_factory( + Rapid, + 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( + Rapid, + 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, +) \ No newline at end of file diff --git a/rapids/models.py b/rapids/models.py index 0a07d1da..3559b98d 100644 --- a/rapids/models.py +++ b/rapids/models.py @@ -129,7 +129,7 @@ class Rapid(models.Model): abnormality = models.ManyToManyField( Abnormality, blank=True, - help_text="The abnormality (laterality and region independent)", + help_text="The abnormality (laterality and region independent). Used for categorisation but does not affect the answer", ) region = models.ManyToManyField( Region, @@ -310,3 +310,113 @@ class RapidCreationDefault(models.Model): def get_absolute_url(self): return reverse('rapids:rapid_create') + +class Exam(models.Model): + name = models.CharField(max_length=200) + exam_questions = SortedManyToManyField( + Rapid, related_name="exams", blank="true" + ) + + active = models.BooleanField( + help_text="If an exam should be available", default=True + ) + + publish_results = models.BooleanField( + help_text="If an exams results should be available", default=True + ) + + recreate_json = models.BooleanField( + help_text="If the json cache needs updating", default=False + ) + + time_limit = models.IntegerField( + help_text="Exam time limit (in seconds)", default=5400 + ) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("rapids:exam_overview", kwargs={"pk": self.pk}) + + def get_exam_name(self): + return self.name + + def get_json_url(self): + return reverse("rapids:exam_json", args=(self.pk,)) + + def get_question_index(self, question): + return list(self.exam_questions.all()).index(question) + +class CidUserAnswer(models.Model): + """User answers by candidate""" + + question = models.ForeignKey( + Rapid, related_name="cid_user_answers", on_delete=models.CASCADE + ) + answer = models.TextField(max_length=500, blank=True) + + answer_compare = models.TextField(max_length=500, blank=True) + + cid = models.BigIntegerField(blank=True, null=True, help_text="Candidate ID (limitied by BigIntegerField size)") + + # Each user answer is associated with a particular exam + exam = models.ForeignKey( + Exam, related_name="cid_user_answers", on_delete=models.CASCADE, null=True + ) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + try: + exam = self.exam + except (Exam.DoesNotExist, KeyError) as e: + exam = "None" + return "{}/{}/{}: {}".format( + exam, self.cid, self.question.GetPrimaryAnswer(), self.answer + ) + + def save(self, *args, **kwargs): + self.clean() + return super(CidUserAnswer, self).save(*args, **kwargs) + + def clean(self): + if self.answer: + self.answer = self.answer.strip() + + s = self.answer.lower() + s = s.translate(str.maketrans('', '', string.punctuation)) + + self.answer_compare = s + + # def get_compare_string(self): + # # strip here should be unneccasry (providing clean is now working) + # s = self.answer.lower().strip() + # s = s.translate(str.maketrans('', '', string.punctuation)) + # return s + + def get_answer_score(self): + q = self.question + ans = self.answer_compare + if ( + q.answers.filter( + answer_compare__iexact=ans, status=Answer.MarkOptions.CORRECT + ).first() + is not None + ): + mark = 2 + elif ( + q.answers.filter( + answer_compare__iexact=ans, status=Answer.MarkOptions.HALF_MARK + ).first() + is not None + ): + mark = 1 + elif q.answers.filter( + answer_compare__iexact=ans, status=Answer.MarkOptions.INCORRECT + ).first(): + mark = 0 + else: + mark = "unmarked" + return mark \ No newline at end of file From 7f52e3183a8945fa6826043b8c0af204a983637f Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 Jan 2021 14:09:45 +0000 Subject: [PATCH 05/32] . --- anatomy/migrations/0020_auto_20210118_1402.py | 20 +++++++ rapids/migrations/0002_auto_20210118_1402.py | 52 +++++++++++++++++++ rapids/templates/rapids/base.html | 30 +++++++---- rapids/templates/rapids/rapid_form.html | 2 +- 4 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 anatomy/migrations/0020_auto_20210118_1402.py create mode 100644 rapids/migrations/0002_auto_20210118_1402.py diff --git a/anatomy/migrations/0020_auto_20210118_1402.py b/anatomy/migrations/0020_auto_20210118_1402.py new file mode 100644 index 00000000..35de5e8f --- /dev/null +++ b/anatomy/migrations/0020_auto_20210118_1402.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.2 on 2021-01-18 14:02 + +import anatomy.models +import django.core.files.storage +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0019_auto_20210118_1339'), + ] + + operations = [ + migrations.AlterField( + model_name='anatomyquestion', + name='image', + field=models.ImageField(storage=django.core.files.storage.FileSystemStorage(base_url='http://penracourses.org.uk//media/anatomy/', location='/home/ross/sites/rad/media//anatomy/'), upload_to=anatomy.models.image_directory_path), + ), + ] diff --git a/rapids/migrations/0002_auto_20210118_1402.py b/rapids/migrations/0002_auto_20210118_1402.py new file mode 100644 index 00000000..56fa041e --- /dev/null +++ b/rapids/migrations/0002_auto_20210118_1402.py @@ -0,0 +1,52 @@ +# Generated by Django 3.1.2 on 2021-01-18 14:02 + +import django.core.files.storage +from django.db import migrations, models +import django.db.models.deletion +import rapids.models +import sortedm2m.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('rapids', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='rapid', + name='abnormality', + field=models.ManyToManyField(blank=True, help_text='The abnormality (laterality and region independent). Used for categorisation but does not affect the answer', to='rapids.Abnormality'), + ), + migrations.AlterField( + model_name='rapidimage', + name='image', + field=models.ImageField(storage=django.core.files.storage.FileSystemStorage(base_url='http://penracourses.org.uk//media/rapids/', location='/home/ross/sites/rad/media//rapids/'), upload_to=rapids.models.image_directory_path), + ), + migrations.CreateModel( + name='Exam', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('active', models.BooleanField(default=True, help_text='If an exam should be available')), + ('publish_results', models.BooleanField(default=True, help_text='If an exams results should be available')), + ('recreate_json', models.BooleanField(default=False, help_text='If the json cache needs updating')), + ('time_limit', models.IntegerField(default=5400, help_text='Exam time limit (in seconds)')), + ('exam_questions', sortedm2m.fields.SortedManyToManyField(blank='true', help_text=None, related_name='exams', to='rapids.Rapid')), + ], + ), + migrations.CreateModel( + name='CidUserAnswer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answer', models.TextField(blank=True, max_length=500)), + ('answer_compare', models.TextField(blank=True, max_length=500)), + ('cid', models.BigIntegerField(blank=True, help_text='Candidate ID (limitied by BigIntegerField size)', null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('exam', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cid_user_answers', to='rapids.exam')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cid_user_answers', to='rapids.rapid')), + ], + ), + ] diff --git a/rapids/templates/rapids/base.html b/rapids/templates/rapids/base.html index e8ad97c7..9b7093a6 100755 --- a/rapids/templates/rapids/base.html +++ b/rapids/templates/rapids/base.html @@ -2,7 +2,7 @@ - + Rapids {% block css %} {% endblock %} @@ -23,8 +23,26 @@ - Rapids -