diff --git a/atlas/__init__.py b/atlas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/atlas/admin.py b/atlas/admin.py new file mode 100755 index 00000000..9a99f7a0 --- /dev/null +++ b/atlas/admin.py @@ -0,0 +1,58 @@ +from django.contrib import admin +from .models import Case, Series, SeriesImage + +import tagulous.admin + +from django.forms import ModelForm + +from reversion.admin import VersionAdmin +from django.forms.widgets import RadioSelect + +from tinymce.widgets import TinyMCE + +# from generic.models import Examination, Sign, Site, Condition +from generic.models import Examination, Sign, Condition + +# Register your models here. +# admin.site.register(Examination) +# admin.site.register(Site) +admin.site.register(SeriesImage) + + + +class AtlasAdminForm(ModelForm): + class Meta: + model = Case + + fields = [ + "description", + "history", + "sign", + "findings", + "author", + "series", + ] + + widgets = { + # "normal": RadioSelect( + # choices=[(True, 'Yes'), + # (False, 'No')]) + "findings" : TinyMCE(attrs={'cols': 80, 'rows': 30}), + "mark_scheme" : TinyMCE(attrs={'cols': 80, 'rows': 30}), + } + +class AtlasAdmin(VersionAdmin): + form = AtlasAdminForm + + save_on_top = True + save_as = True + view_on_site = True + + +admin.site.register(Case, AtlasAdmin) + + +class SeriesAdmin(VersionAdmin): + pass + +admin.site.register(Series, SeriesAdmin) \ No newline at end of file diff --git a/atlas/apps.py b/atlas/apps.py new file mode 100644 index 00000000..f365394c --- /dev/null +++ b/atlas/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AtlasConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'atlas' diff --git a/atlas/decorators.py b/atlas/decorators.py new file mode 100755 index 00000000..89b4b26e --- /dev/null +++ b/atlas/decorators.py @@ -0,0 +1,66 @@ +from django.core.exceptions import PermissionDenied +from .models import Atlas, Series + +def user_is_author_or_atlas_series_checker_or_atlas_marker(function): + def wrap(request, *args, **kwargs): + series = Series.objects.get(pk=kwargs['pk']) + if request.user in series.get_author_objects() or request.user.groups.filter(name='atlas_checker').exists() or request.user.groups.filter(name='atlas_marker').exists() or request.user.is_superuser: + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap + +def user_is_author_or_atlas_series_checker(function): + def wrap(request, *args, **kwargs): + series = Series.objects.get(pk=kwargs['pk']) + if request.user in series.get_author_objects() or request.user.groups.filter(name='atlas_checker').exists() or request.user.is_superuser: + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap + +def user_is_author_or_atlas_checker_or_atlas_marker(function): + def wrap(request, *args, **kwargs): + atlas = Atlas.objects.get(pk=kwargs['pk']) + if request.user in atlas.author.all() or request.user.groups.filter(name='atlas_checker').exists() or request.user.groups.filter(name='atlas_marker').exists() or request.user.is_superuser: + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap + +def user_is_author_or_atlas_checker(function): + def wrap(request, *args, **kwargs): + atlas = Atlas.objects.get(pk=kwargs['pk']) + if request.user in atlas.author.all() or request.user.groups.filter(name='atlas_checker').exists() or request.user.is_superuser: + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap + +def user_is_atlas_checker(function): + def wrap(request, *args, **kwargs): + if request.user.groups.filter(name='atlas_checker').exists() or request.user.is_superuser: + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap + +def user_is_atlas_marker(function): + def wrap(request, *args, **kwargs): + if request.user.groups.filter(name='atlas_marker').exists() or request.user.is_superuser: + return function(request, *args, **kwargs) + else: + raise PermissionDenied + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap \ No newline at end of file diff --git a/atlas/filters.py b/atlas/filters.py new file mode 100755 index 00000000..d0c6ac54 --- /dev/null +++ b/atlas/filters.py @@ -0,0 +1,42 @@ +import django_filters + +from .models import Case, Series +from django.contrib.auth.models import User + +def get_authors(request): + if request is None: + return User.objects.none() + + if not request.user.groups.filter(name="atlas_checker").exists(): + queryset = User.objects.filter(id=request.user.id) + else: + queryset = User.objects.all() + + return queryset + + +class AtlasFilter(django_filters.FilterSet): + author = django_filters.ModelMultipleChoiceFilter(queryset=get_authors, null_label="No author") + class Meta: + model = Case + fields = (#"site", + "created_date", "open_access", "author") + + def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None): + if not request.user.groups.filter(name="atlas_checker").exists(): + #queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id) + queryset = queryset.filter(author__id=request.user.id) + super(AtlasFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request) + pass + +class SeriesFilter(django_filters.FilterSet): + class Meta: + model = Series + fields = ("modality","atlas", "examination", "author") + + def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None): + if not request.user.groups.filter(name="atlas_checker").exists(): + #queryset = queryset.filter(open_access=True) | queryset.filter(author__id=request.user.id) + queryset = queryset.filter(author__id=request.user.id) + super(SeriesFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request) + pass \ No newline at end of file diff --git a/atlas/forms.py b/atlas/forms.py new file mode 100755 index 00000000..f96fd7ca --- /dev/null +++ b/atlas/forms.py @@ -0,0 +1,160 @@ +from django.forms import ( + Form, + ModelForm, + ModelMultipleChoiceField, + ModelChoiceField, + ChoiceField, + CharField, +) +from django.forms import inlineformset_factory + +from atlas.models import ( + Case, + Series, + SeriesImage, +) + +from anatomy.models import Modality + +from generic.models import Examination, Condition, Sign +#from generic.models import Examination, Site, Condition, Sign + +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms.widgets import RadioSelect, TextInput, Textarea + +from tinymce.widgets import TinyMCE + +class ExaminationForm(ModelForm): + class Meta: + model = Examination + fields = ["examination"] + +class SeriesForm(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(SeriesForm, self).__init__(*args, **kwargs) + #self.fields["examination"] = ModelChoiceField( + # queryset=Examination.objects.all(), + # widget=FilteredSelectMultiple(verbose_name="Examination", is_stacked=False), + #) + + self.fields["modality"] = ModelChoiceField( + required=True, + queryset=Modality.objects.all(), + ) + + ModelForm.__init__(self, *args, **kwargs) + + class Meta: + model = Series + exclude = ["author"] + + +class AtlasForm(ModelForm): + + #exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False)) + + class Media: + # Django also includes a few javascript files necessary + # for the operation of this form element. You need to + # include + # in the template. + css = { + "all": ["css/widgets.css"], + } + # Adding this javascript is crucial + js = ["jsi18n.js", "tesseract.min.js"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user') # To get request.user. Do not use kwargs.pop('user', None) due to potential security hole + if kwargs.get("instance"): + # We get the 'initial' keyword argument or initialize it + # as a dict if it didn't exist. + initial = kwargs.setdefault("initial", {}) + # The widget for a ModelMultipleChoiceField expects + # a list of primary key for the selected data. + initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] + + ModelForm.__init__(self, *args, **kwargs) + + super(AtlasForm, self).__init__(*args, **kwargs) + + + class Meta: + model = Case + # fields = ['due_back'] + # fields = '__all__' + fields = [ + #"examination", + #"site", + "description", + "history", + "findings", + "condition", + "sign", + "open_access", + ] + # fields = ['question', 'findings', 'subspecialty', 'references'] + widgets = { + # "normal": RadioSelect( + # choices=[(True, 'Yes'), + # (False, 'No')]) + "findings" : TinyMCE(attrs={'cols': 80, 'rows': 20}), + "mark_scheme" : TinyMCE(attrs={'cols': 80, 'rows': 30}), + "description" : Textarea(attrs={'cols': 80, 'rows': 5}), + "history" : Textarea(attrs={'cols': 80, 'rows': 5}), + } + + def save(self, commit=True): + # Get the unsaved Case instance + instance = ModelForm.save(self, False) + + # Prepare a 'save_m2m' method for the form, + old_save_m2m = self.save_m2m + + def save_m2m(): + old_save_m2m() + #instance.exams.clear() + #for exam in self.cleaned_data["exams"]: + # # We need the id here + # instance.exams.add(exam.pk) + + self.save_m2m = save_m2m + + # Do we need to save all changes now? + # if commit: + instance.save() + self.save_m2m() + + return instance + +SeriesFormSet = inlineformset_factory( + Case, + Series.case.through, + exclude=[], + can_delete=True, + extra=0, + max_num=10, + field_classes="testing", +) + + +SeriesImageFormSet = inlineformset_factory( + Series, + SeriesImage, + exclude=[], + can_delete=True, + extra=0, + max_num=2000, + field_classes="testing", +) \ No newline at end of file diff --git a/atlas/migrations/__init__.py b/atlas/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/atlas/models.py b/atlas/models.py new file mode 100644 index 00000000..b0cbfd60 --- /dev/null +++ b/atlas/models.py @@ -0,0 +1,378 @@ +import json +from rad.settings import REMOTE_URL +from django.db.models.fields.files import ImageField +from django.db.models.fields.related import ForeignKey +from django.db import models +from django.shortcuts import get_object_or_404 +from django.utils import timezone +import tagulous +import tagulous.models + + +from django.core.files.storage import FileSystemStorage +from django.conf import settings +from django.utils.html import format_html + +from django.urls import reverse + +from django.utils.translation import ugettext_lazy as _ +from django.utils.html import mark_safe + +from django.core.exceptions import ValidationError + +from sortedm2m.fields import SortedManyToManyField + +import string +from collections import defaultdict +from helpers.images import image_as_base64, pretty_print_dicom + +from anatomy.models import Modality + +from generic.models import Examination, Condition, Sign, ExamBase, Plane, Contrast, QuestionNote + +# from generic.models import Examination, Site, Condition, Sign + +from easy_thumbnails.files import get_thumbnailer +from easy_thumbnails.exceptions import InvalidImageFormatError + +import pydicom +import pydicom.errors +import datetime +from django.utils import timezone + +import reversion + +from django.contrib.contenttypes.fields import GenericRelation + + +image_storage = FileSystemStorage( + # Physical file location ROOT + location=u"{0}atlas/".format(settings.MEDIA_ROOT), + # Url for file + base_url=u"{0}atlas/".format(settings.MEDIA_URL), +) + + +def image_directory_path(instance, filename): + return u"atlas/picture/{0}".format(filename) + + +def findMiddle(input_list): + middle = float(len(input_list)) / 2 + if middle % 2 != 0: + return input_list[int(middle - 0.5)] + else: + return input_list[int(middle)] + return (input_list[int(middle)], input_list[int(middle - 1)]) + + +@reversion.register +class Case(models.Model): + class SubspecialtyChoices(models.TextChoices): + BREAST = 'BR', _('Breast') + CARDIAC = 'CA', _('Cardiac') + GASTRO = 'GI', _('Gastrointestinal and hepatobiliary') + HEADNECK = 'HN', _('Head and Neck') + MSK = 'MS', _('Musculoskeletal') + NEURO = 'NE', _('Neuroradiology') + OBSGYN = 'OG', _('Obstectric and Gynaecological') + PAED = 'PA', _('Paediatric') + URO = 'UR', _('Uroradiology') + VASC = 'VA', _('Vascular') + HAEMONC = 'HA', _('Haemotology and Oncology') + + # author = models.ForeignKey('auth.User', on_delete=models.CASCADE) + # image = models.ImageField() + description = models.TextField( + blank=True, + help_text="Description of the case, for admin organisation, will not be visible when taking", + ) + + history = models.TextField(null=True, blank=True) + + findings = models.TextField(null=True, blank=True) + + subpecialty = models.CharField(max_length=2, choices=SubspecialtyChoices.choices) + + 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" + ) + + 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="atlas_authored_questions", + ) + + scrapped = models.BooleanField( + default=False, help_text="Question has been scrapped and will not be shown" + ) + + open_access = models.BooleanField( + help_text="If a question should be freely available to browse", default=True + ) + + series = SortedManyToManyField("Series", related_name="atlas") + + notes = GenericRelation(QuestionNote) + + #question_file = models.FileField(upload_to=question_file_directory_path, blank=True, null=True) + + def get_absolute_url(self): + return reverse("atlas:case_detail", kwargs={"pk": self.pk}) + + def get_authors(self): + """Returns a comma seperated text list of authors""" + authors = ", ".join([i.username for i in self.author.all()]) + return authors + + def get_author_objects(self): + """Returns a comma seperated text list of authors""" + authors = [i for i in self.author.all()] + return authors + + def __str__(self): + examinations = [series.get_examination() for series in self.series.all()] + return "{}/{} : {}".format(self.pk, self.description, ", ".join(examinations)) + + def get_images(self, findings=False): + qs = self.images.all() + + if not findings: + images = [i.image for i in qs if not i.findings_image] + else: + images = [i.image for i in qs] + + return images + + def get_image_urls(self): + return ",".join( + ["https://www.penracourses.org.uk{}".format(i.url) for i in self.get_images()] + ) + + def get_image_url_array(self): + return json.dumps(["https://www.penracourses.org.uk{}".format(i.url) for i in self.get_images()]) + + +class SeriesImage(models.Model): + image = models.FileField(upload_to=image_directory_path) + position = models.IntegerField(default=0) + upload_filename = models.CharField(max_length=255, blank=True) + series = models.ForeignKey( + "Series", related_name="images", on_delete=models.SET_NULL, null=True + ) + + class Meta: + ordering = ['position'] + + def get_dicom_info(self): + try: + info = pretty_print_dicom(pydicom.read_file(self.image)) + except pydicom.errors.InvalidDicomError: + info = "File is not a dicom." + return(info) + + +@reversion.register +class Series(models.Model): + modality = models.ForeignKey( + Modality, related_name="series_modality", on_delete=models.SET_NULL, null=True + ) + examination = models.ForeignKey( + Examination, + help_text="Name of the examination", + related_name="series_examination", + on_delete=models.SET_NULL, + null=True, + ) + plane = models.ForeignKey( + Plane, + help_text="Plane of the examination", + related_name="series_plane", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + contrast = models.ForeignKey( + Contrast, + help_text="MRI / CT contrast", + related_name="series_contrast", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + description = models.TextField( + blank=True, + help_text="Description of stack, for admin organisation, will not be visible when taking", + ) + + author = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="series", + ) + + open_access = models.BooleanField( + help_text="If a question should be freely available to browse", default=True + ) + + + def __str__(self): + if self.atlas: + atlas_id = ", ".format([atlas.pk for atlas in self.atlas.all()]) + #atlas_id = self.atlas.pk + else: + atlas_id = "None" + return "{}/{} : {} [{}]".format(self.pk, + self.get_examination(), self.description, atlas_id + ) + + def get_author_objects(self): + """Returns a comma seperated text list of authors""" + if self.author: + return self.author.all() + else: + return ["None"] + if self.atlas: + authors = [i for i in self.atlas.author.all()] + else: + authors = [] + + return authors + + def get_author_display(self): + return ", ".join([i.username for i in self.get_author_objects()]) + + def get_examination(self): + """Returns a comma seperated text list of regions""" + return str(self.examination) + + def get_examination_full(self): + examination = "" + plane = "" + contrast = "" + + if self.examination: + examination = self.examination + if self.plane: + plane = " {}".format(self.plane) + if self.contrast: + contrast = " {}".format(self.contrast) + return "{}{}{}".format(examination, plane, contrast) + + def get_absolute_url(self): + return reverse("atlas:series_detail", kwargs={"pk": self.pk}) + + def get_image_urls(self, findings=False): + images = [ + "https://www.penracourses.org.uk{}".format(i.image.url) + for i in self.images.all() + ] + + return ",".join(images) + + def get_image_url_array(self, findings=False): + images = [ + "https://www.penracourses.org.uk{}".format(i.image.url) + for i in self.images.all() + ] + + return json.dumps(images) + + def get_thumbnail(self): + images = self.images.all() + + if len(images) < 1: + return "No images", 0 + + img = findMiddle(images).image + try: + thumbnailer = get_thumbnailer(img) + thumbnail = thumbnailer["exam-list"] + except InvalidImageFormatError: + return format_html('Invalid image url', img), len(images) + return format_html('', thumbnail), len(images) + + def get_block(self): + examination = self.get_examination_full() + thumb, image_number = self.get_thumbnail() + return format_html( + "
{}
{}
Images: {}
", examination, thumb, image_number + ) + + def order_by_upload_filename(self): + images = self.images.all() + + filenames = [] + map = {} + + for i in images: + filenames.append(i.upload_filename) + map[i.upload_filename] = i + + filenames = sorted(filenames) + + n = 1 + for f in filenames: + i = map[f] + i.position = n + i.save() + n = n + 1 + + + + def order_by_dicom(self, field="SliceLocation"): + images = self.images.all() + + files = [] + + for i in images: + files.append((i, pydicom.dcmread(i.image.path))) + + #print("file count: {}".format(len(files))) + + # skip files with no SliceLocation (eg scout views) + slices = [] + map = {} + skipcount = 0 + for i, f in files: + if hasattr(f, field): + slices.append(f) + #map[f.SliceLocation] = i + map[f[field].value] = i + else: + skipcount = skipcount + 1 + + print("skipped, no {}: {}".format(field, skipcount)) + + # ensure they are in the correct order + slices = sorted(slices, key=lambda s: s[field].value) + + + #print(slices) + n = 1 + for f in slices: + i = map[f[field].value] + i.position = n + i.save() + n = n + 1 + + def get_total_image_size(self): + images = self.images.all() + + size = 0 + + for i in images: + size += i.image.size + + return size diff --git a/atlas/serializers.py b/atlas/serializers.py new file mode 100644 index 00000000..a447defc --- /dev/null +++ b/atlas/serializers.py @@ -0,0 +1,11 @@ +# serializers.py +from rest_framework import serializers, permissions + + +from .models import Exam + +class ExamSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = Exam + fields = ('name', 'id', 'active', "publish_results") + permission_classes = [permissions.IsAuthenticated] \ No newline at end of file diff --git a/atlas/tables.py b/atlas/tables.py new file mode 100755 index 00000000..daf42c16 --- /dev/null +++ b/atlas/tables.py @@ -0,0 +1,114 @@ +import django_tables2 as tables +from django_tables2.utils import A + +from .models import Case, Series + +from django.utils.html import format_html + +from easy_thumbnails.files import get_thumbnailer + +from easy_thumbnails.exceptions import InvalidImageFormatError + + +class AtlasImageColumn(tables.Column): + def render(self, value): + blocks =[] + for obj in value.all(): + blocks.append(obj.get_block()) + return format_html("{}".format("".join(blocks))) + + obj = value.first() + + if obj is None: + return format_html("No image") + + return obj.get_thumbnail()[0] + + # image_object = obj.image + # try: + # thumbnailer = get_thumbnailer(image_object) + # return format_html('', thumbnailer["exam-list"]) + # except InvalidImageFormatError: + # return format_html('Invalid image url', image_object) + + +class SeriesImageColumn(tables.Column): + def render(self, value): + obj = value.first() + + if obj is None: + return "None" + + image_object = obj.image + try: + thumbnailer = get_thumbnailer(image_object) + return format_html('
[{}]', thumbnailer["exam-list"], value.count()) + except InvalidImageFormatError: + return format_html('Invalid image url
[{}]', image_object, value.count()) + + +class AtlasTable(tables.Table): + edit = tables.LinkColumn( + "atlas:atlas_update", text="Edit", args=[A("pk")], orderable=False + ) + view = tables.LinkColumn( + "atlas:case_detail", text="View", args=[A("pk")], orderable=False + ) + clone = tables.LinkColumn( + "atlas:case_clone", text="Clone", args=[A("pk")], orderable=False + ) + delete = tables.LinkColumn( + "atlas:atlas_delete", text="Delete", args=[A("pk")], orderable=False + ) + series = AtlasImageColumn("Images", orderable=False) + + #series = tables.ManyToManyColumn(verbose_name="Exams") + exams = tables.ManyToManyColumn(verbose_name="Exams") + + selection = tables.CheckBoxColumn(accessor="pk", orderable=False) + + class Meta: + model = Case + template_name = "django_tables2/bootstrap4.html" + fields = ("description", "history", "created_date", "author") + sequence = ("view", "series", "exams") + +class PopupLinkColumn(tables.Column): + def render(self, value): + return format_html("test: {}", value) + obj = value.first() + + if obj is None: + return format_html("No image") + + return obj.get_thumbnail()[0] + +def create_link(test): + print(test) + return "Hello" + +class SeriesTable(tables.Table): + edit = tables.LinkColumn( + "atlas:series_update", text="Edit", args=[A("pk")], orderable=False + ) + view = tables.LinkColumn( + "atlas:series_detail", text="View", args=[A("pk")], orderable=False + ) + popup = tables.Column(empty_values=(), orderable=False) + + images = SeriesImageColumn("Images", orderable=False) + + delete = tables.LinkColumn( + "atlas:series_delete", text="Delete", args=[A("pk")], orderable=False + ) + selection = tables.CheckBoxColumn(accessor="pk", orderable=False) + + class Meta: + model = Series + template_name = "django_tables2/bootstrap4.html" + fields = ("modality", "examination", "atlas", "description", "author") + sequence = ("view", "popup", "images") + + def render_popup(self, value, record): + print(self) + return format_html("""Popup""", record.pk) \ No newline at end of file diff --git a/atlas/tests.py b/atlas/tests.py new file mode 100755 index 00000000..7ce503c2 --- /dev/null +++ b/atlas/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/atlas/urls.py b/atlas/urls.py new file mode 100755 index 00000000..102eff23 --- /dev/null +++ b/atlas/urls.py @@ -0,0 +1,191 @@ +from django.urls import path, include +from . import views + +app_name = "atlas" + +urlpatterns = [ + # path('', views.question_list, name='question_list'), + path("", views.AtlasExamViews.index, name="index"), + path("author//", views.author_detail, name="author_detail"), + path("author/", views.author_list, name="author_list"), + path("question/", views.CaseView.as_view(), name="atlas_view"), + path("series/", views.SeriesView.as_view(), name="series_view"), + path("series/", views.series_detail, name="series_detail"), + path( + "series//order_dicom", + views.series_order_dicom, + name="series_order_dicom", + ), + path( + "series//download", + views.SeriesImagesZipView.as_view(), + name="series_download", + ), + path( + "series//order_dicom_instance", + views.series_order_dicom_instance, + name="series_order_dicom_instance", + ), + path( + "series//order_dicom_SeriesInstanceUID", + views.series_order_dicom_SeriesInstanceUID, + name="series_order_dicom_SeriesInstanceUID", + ), + path( + "series//order_upload_filename", + views.series_order_upload_filename, + name="series_order_upload_filename", + ), + path( + "series//delete", + views.SeriesDelete.as_view(), + name="series_delete", + ), + # path("unchecked/", views.unchecked_list, name="unchecked_list"), + # path("verified//", views.verified_detail, name="verified_detail"), + path("question//", views.case_detail, name="case_detail"), + path("question//json", views.question_json, name="question_json"), + path( + "question//json/unbased", + views.question_json_unbased, + name="question_json_unbased", + ), + path( + "question//json/recreate", + views.question_json_recreate, + name="question_json_recreate", + ), + path("question//split", views.atlas_split, name="atlas_split"), + path("question//clone", views.AtlasClone.as_view(), name="case_clone"), + # path("verified/", views.verified, name="verified"), + # path("all_questions/", views.all_questions, name="all_questions"), + path("question//scrap", views.atlas_scrap, name="atlas_scrap"), + path("question//delete", views.AtlasDelete.as_view(), name="atlas_delete"), + path( + "exam//review", + views.question_review, + name="question_review", + ), + path("exam////mark", views.mark_answer, name="mark_answer"), + path("exam////mark_override", views.mark_answer_override, name="mark_answer_override"), + path("exam///mark", views.mark_question_overview, name="mark_question_overview"), + path("exam//mark", views.AtlasExamViews.mark_overview, name="mark_overview"), + # path("exam///", views.exam_take, name="exam_take"), + path( + "exam//question//", + views.AtlasExamViews.exam_case_detail, + name="exam_case_detail", + ), + path("exam//", views.AtlasExamViews.exam_overview, name="exam_overview"), + path( + "exam//json_edit", + views.AtlasExamViews.exam_json_edit, + name="exam_json_edit", + ), + path( + "exam//question_json_refresh", + views.refresh_exam_question_json, + name="refresh_exam_question_json", + ), + path("exam//scores", views.exam_scores_cid, name="exam_scores_cid"), + path( + "exam//scores//", + views.exam_scores_cid_user, + name="exam_scores_cid_user", + ), + path( + "exam//toggle_active", + views.AtlasExamViews.exam_toggle_active, + name="exam_toggle_active", + ), + path( + "exam//toggle_results_published", + views.AtlasExamViews.exam_toggle_results_published, + name="exam_toggle_results_published", + ), + path( + "exam/submit", views.AtlasExamViews.postExamAnswers, name="exam_answers_submit" + ), + path("exam/", views.AtlasExamViews.exam_list, name="exam_list"), + path("exam/all", views.AtlasExamViews.exam_list_all, name="exam_list_all"), + path("exam/create", views.ExamCreate.as_view(), name="exam_create"), + path("exam//clone", views.ExamClone.as_view(), name="exam_clone"), + path("exam//update", views.ExamUpdate.as_view(), name="exam_update"), + path("exam//delete", views.ExamDelete.as_view(), name="exam_delete"), + path("exam/json/", views.AtlasExamViews.active_exams, name="active_exams"), + path("exam/json/", views.AtlasExamViews.exam_json, name="exam_json"), + path( + "exam/json//unbased", + views.AtlasExamViews.exam_json_unbased, + name="exam_json_unbased", + ), + path( + "exam/json//", + views.AtlasExamViews.exam_question_json, + name="exam_question_json", + ), + path( + "exam/json//unbased/", + views.AtlasExamViews.exam_question_json_unbased, + name="exam_question_json_unbased", + ), + path( + "exam/json//recreate", + views.AtlasExamViews.exam_json_recreate, + name="exam_json_recreate", + ), + path("create/", views.AtlasCreate.as_view(), name="create"), + path("create/series", views.SeriesCreate.as_view(), name="series_create"), + path( + "create/series/", + views.SeriesCreate.as_view(), + name="series_id_create", + ), + path( + "create/defaults", + views.AtlasCreationDefaultView.as_view(), + name="create_defaults", + ), + # 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( + "question//update", + views.AtlasUpdate.as_view(), + name="atlas_update", + ), + path( + "series//update", + views.SeriesUpdate.as_view(), + name="series_update", + ), + path( + "user_answers/", + views.UserAnswerTableView.as_view(), + name="user_answer_table_view", + ), + path( + "user_answers/", views.UserAnswerView.as_view(), name="user_answer_view" + ), + path( + "user_answers//delete", + views.UserAnswerDelete.as_view(), + name="user_answer_delete", + ), + path( + "user_answers/delete", + views.user_answer_delete_multiple, + name="user_answer_delete_multiple", + ), +] diff --git a/atlas/views.py b/atlas/views.py new file mode 100755 index 00000000..aa2501fa --- /dev/null +++ b/atlas/views.py @@ -0,0 +1,547 @@ +import json +from django.shortcuts import render, get_object_or_404, redirect +from django import forms + +# from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.models import User +from django.core.exceptions import ( + ObjectDoesNotExist, + PermissionDenied, + ViewDoesNotExist, +) + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic.detail import DetailView +from generic.mixins import SuperuserRequiredMixin + +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.views.generic import ListView +from django.views.decorators.csrf import csrf_exempt + +from django.urls import reverse_lazy, reverse + +from django.http import Http404, JsonResponse +from django.http import HttpResponseRedirect, HttpResponse + +from .forms import ( + AtlasForm, + SeriesForm, + SeriesImageFormSet, + MarkAtlasQuestionDoubleForm, + MarkAtlasQuestionSingleForm, + SeriesFormSet, + ExaminationForm, + ExamForm, +) +from .models import ( + Case, + Series, + Examination, +) +from .tables import AtlasTable, SeriesTable, UserAnswerTable +from .filters import AtlasFilter, SeriesFilter, UserAnswerFilter + +from django_tables2 import SingleTableView, SingleTableMixin +from django_filters.views import FilterView + +from .decorators import ( + user_is_author_or_atlas_checker, + user_is_author_or_atlas_checker_or_atlas_marker, + user_is_author_or_atlas_series_checker_or_atlas_marker, + user_is_atlas_checker, + user_is_author_or_atlas_series_checker, + user_is_atlas_marker, + user_is_author_or_atlas_checker_or_atlas_marker, +) + +from collections import defaultdict +import json +import statistics +import plotly.express as px + +from django.core.cache import cache +from django.conf import settings + +from helpers.images import image_as_base64 + +import logging +from copy import deepcopy +from django.forms.models import model_to_dict +from atlas.forms import AtlasCreationDefaultForm +from atlas.models import AtlasCreationDefault + +from generic.views import ExamCloneMixin, ExamViews +from reversion.views import RevisionMixin +import reversion + + +from rest_framework import viewsets + +from .serializers import ExamSerializer + +from zipview.views import BaseZipView + +import os + +logger = logging.getLogger(__name__) + + +class AuthorOrCheckerRequiredMixin(object): + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if ( + self.request.user.groups.filter(name="atlas_checker").exists() + or self.request.user.is_superuser + ): + return obj + if self.request.user not in obj.get_author_objects(): + raise PermissionDenied() # or Http404 + return obj + + +@login_required +@user_is_author_or_atlas_checker_or_atlas_marker +def case_detail(request, pk): + atlas = get_object_or_404(Case, pk=pk) + + # if request.user not in atlas.author.all(): + # raise PermissionDenied + + # logging.debug(atlas.subspecialty.first().name.all()) + return render(request, "atlas/case_detail.html", {"question": atlas}) + + +@login_required +@user_is_author_or_atlas_series_checker_or_atlas_marker +def atlas_series_detail(request, pk): + series = get_object_or_404(Series, pk=pk) + + # if request.user not in atlas.author.all(): + # raise PermissionDenied + + # logging.debug(atlas.subspecialty.first().name.all()) + return render(request, "atlas/atlas_series.html", {"series": series}) + + +@login_required +@user_is_author_or_atlas_checker +def author_detail(request, pk): + # logging.debug(Author.objects.all()) + # author = get_object_or_404(Author, pk=pk) + author = User.objects.get(pk=pk) + + atlas = Case.objects.filter(author=pk) + + return render( + request, "atlas/category_detail.html", {"category": author, "atlas": atlas} + ) + + +@user_is_author_or_atlas_checker +def author_list(request): + authors = User.objects.all() + + return render(request, "atlas/author_list.html", {"authors": authors}) + + + +class AtlasDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Case + success_url = reverse_lazy("atlas:atlas_view") + + +class SeriesDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): + model = Series + success_url = reverse_lazy("atlas:atlas_series_view") + + +@login_required +@user_is_author_or_atlas_checker +def case_clone(request, pk): + new_item = get_object_or_404(Case, pk=pk) + new_item.pk = None # autogen a new pk (item_id) + # new_item.name = "Copy of " + new_item.name #need to change uniques + + form = AtlasForm(request.POST or None, instance=new_item) + + series_formset = SeriesFormSet() + + if form.is_valid(): + form.instance.author.add(request.user.id) + + # logger.debug(formset.is_valid()) + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = obj + series_formset.save() + + return response + else: + return super().form_invalid(form) + + context = { + "form": form, + "series_formset": series_formset, + # other context + } + + return render(request, "atlas/atlas_form.html", context) + + +class SeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView): + model = Series + form_class = SeriesForm + + def get_initial(self): + # print(self.request) + if "pk" in self.kwargs: + initial = super().get_initial() + case = get_object_or_404(Case, pk=self.kwargs["pk"]) + + initial["case"] = case.id + + return initial + + def get_context_data(self, **kwargs): + context = super(SeriesCreate, self).get_context_data(**kwargs) + + if self.request.POST: + context["image_formset"] = SeriesImageFormSet( + self.request.POST, self.request.FILES + ) + context["image_formset"].full_clean() + else: + context["image_formset"] = SeriesImageFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["image_formset"] + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + return response + + else: + return super().form_invalid(form) + + +class SeriesUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Series + form_class = SeriesForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_context_data(self, **kwargs): + context = super(SeriesUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["image_formset"] = SeriesImageFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["image_formset"].full_clean() + else: + context["image_formset"] = SeriesImageFormSet(instance=self.object) + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + self.object.save() + + # Do this in the form.save() + # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) + # self.object.exams.clear() + # self.object.exams.add(*new_exams) + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + image_formset = context["image_formset"] + # logger.debug(formset.is_valid()) + if image_formset.is_valid(): + response = super().form_valid(form) + image_formset.instance = self.object + image_formset.save() + return response + else: + return super().form_invalid(form) + + +class AtlasCreateBase(RevisionMixin, LoginRequiredMixin, CreateView): + model = Case + form_class = AtlasForm + + def get_form_kwargs(self): + kwargs = super(AtlasCreateBase, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(AtlasCreateBase, self).get_context_data(**kwargs) + if self.request.POST: + context["series_formset"] = SeriesFormSet( + self.request.POST, self.request.FILES + ) + context["series_formset"].full_clean() + else: + context["series_formset"] = SeriesFormSet() + return context + + def form_valid(self, form): + + self.object = form.save(commit=False) + + # Add exam objects + + self.object.save() + + # Do this in the form.save() + # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) + # self.object.exams.clear() + # self.object.exams.add(*new_exams) + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["series_formset"] + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + # If the normal submit button is pressed we save as normal + if "submit" in self.request.POST: + return response + # else we redirect to the clone url + else: + return redirect("atlas:case_clone", pk=self.object.pk) + + else: + return super().form_invalid(form) + + +# @login_required +class AtlasCreate(AtlasCreateBase): + def get_initial(self): + # There has to be a better way... + try: + s = (i.pk for i in self.request.user.atlas_default.site.all()) + self.initial.update({"site": s}) + except AttributeError: + pass + return self.initial + + + +class AtlasUpdate( + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): + model = Case + form_class = AtlasForm + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + + def get_form_kwargs(self): + kwargs = super(AtlasUpdate, self).get_form_kwargs() + kwargs.update({"user": self.request.user}) + return kwargs + + def get_context_data(self, **kwargs): + context = super(AtlasUpdate, self).get_context_data(**kwargs) + if self.request.POST: + context["series_formset"] = SeriesFormSet( + self.request.POST, self.request.FILES, instance=self.object + ) + context["series_formset"].full_clean() + else: + context["series_formset"] = SeriesFormSet(instance=self.object) + return context + + def form_valid(self, form): + # save exam orders (there must be a better way to do this) + exam_orders = {} + for exam in self.object.exams.all(): + exam_orders[exam] = list(exam.exam_questions.all()) + + self.object = form.save(commit=False) + self.object.save() + + # Do this in the form.save() + # new_exams = list(form.cleaned_data['exams'].values_list("pk", flat=True)) + # self.object.exams.clear() + # self.object.exams.add(*new_exams) + + form.instance.author.add(self.request.user.id) + + context = self.get_context_data(form=form) + series_formset = context["series_formset"] + # logger.debug(formset.is_valid()) + if series_formset.is_valid(): + response = super().form_valid(form) + series_formset.instance = self.object + series_formset.save() + + # restore exam orders + for exam in self.object.exams.all(): + if exam in exam_orders and self.object in exam_orders[exam]: + print(exam_orders[exam]) + exam.exam_questions.set(exam_orders[exam]) + exam.save() + + return response + else: + return super().form_invalid(form) + + +class AtlasClone(AtlasCreateBase): + """Clones a existing atlas""" + + # fields = '__all__' + # #fields = [ 'condition' ] + # #initial = {'date_of_death': '05/01/2018'} + # exclude = [ 'created_date', 'published_date' ] + def get_initial(self): + # print(self.request) + old_object = get_object_or_404(Case, pk=self.kwargs["pk"]) + initial_data = model_to_dict(old_object, exclude=["id"]) + + return initial_data + + +@login_required +@user_is_author_or_atlas_checker +def atlas_scrap(request, pk): + try: + atlas = Case.objects.get(pk=pk) + except Case.DoesNotExist: + raise Http404("Case does not exist") + + atlas.scrapped = not atlas.scrapped + atlas.save() + return HttpResponseRedirect(reverse("atlas:case_detail", args=(pk,))) + + +# @login_required +# def edit_abnormality_popup(request): +# instance = get_object_or_404(Abnormality, pk = pk) +# form = AbnormalityForm(request.POST or None) +# if form.is_valid(): +# instance = form.save() +# return HttpResponse('' % (instance.pk, instance)) +# return render(request, "atlas/create_simple.html", {'form': form}) + + +@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, "atlas/create_simple.html", {"form": form, "name": "Examination"} + ) + + +@csrf_exempt +def get_examination_id(request): + if request.is_ajax(): + examination_name = request.GET["examination_name"] + examination_id = Examination.objects.get(name=examination_name).id + data = { + "examination_id": examination_id, + } + return HttpResponse(json.dumps(data), content_type="application/json") + return HttpResponse("/") + + +class AtlasView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Case + table_class = AtlasTable + template_name = "atlas/view.html" + + filterset_class = AtlasFilter + + +class SeriesView(LoginRequiredMixin, SingleTableMixin, FilterView): + model = Series + table_class = SeriesTable + template_name = "atlas/view.html" + + filterset_class = SeriesFilter + + +@login_required +@user_is_author_or_atlas_series_checker +def atlas_series_order_dicom(request, pk): + series = get_object_or_404(Series, pk=pk) + try: + series.order_by_dicom() + except: + return HttpResponse("

Series does not appear to contain dicoms

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

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

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

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

" + ) + + return redirect("atlas:atlas_series_detail", pk=pk) + + +@login_required +@user_is_author_or_atlas_series_checker +def atlas_series_order_upload_filename(request, pk): + series = get_object_or_404(Series, pk=pk) + series.order_by_upload_filename() + + return redirect("atlas:atlas_series_detail", pk=pk) + + + +class SeriesImagesZipView(SuperuserRequiredMixin, BaseZipView): + """Download all images from an image series""" + + def get_files(self): + series = Series.objects.get(pk=self.kwargs["pk"]) + + return [i.image.file for i in series.images.all()] +