From 98756baa373e79f03bda0bd86274477400fdef4a Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 8 Apr 2024 14:16:13 +0100 Subject: [PATCH] i should break these commits up --- atlas/forms.py | 38 +- atlas/helpers.py | 10 + ...ted_userreportanswer_completed_and_more.py | 59 +++ atlas/models.py | 100 ++++-- .../atlas/add_case_to_collection.html | 2 +- atlas/templates/atlas/base.html | 2 + atlas/templates/atlas/case_display_block.html | 336 +++++++++--------- atlas/templates/atlas/case_form.html | 68 ++-- .../templates/atlas/casecollection_form.html | 33 +- .../atlas/collection_case_view_take.html | 44 ++- atlas/templates/atlas/collection_detail.html | 27 +- .../atlas/collection_take_overview.html | 11 +- .../atlas/collection_update_form.html | 17 - atlas/templates/atlas/exams.html | 6 +- atlas/templates/atlas/resource_detail.html | 32 ++ atlas/templates/atlas/resource_form.html | 26 ++ .../templates/atlas/resource_link_header.html | 10 + atlas/templates/atlas/resource_list.html | 21 ++ atlas/templates/atlas/selfreview_form.html | 2 +- atlas/urls.py | 24 ++ atlas/views.py | 210 ++++++++--- rad/static/css/anatomy.css | 35 -- rad/static/css/forms.css | 89 +++++ templates/base.html | 1 + templates/exam_create_form.html | 2 +- 25 files changed, 839 insertions(+), 366 deletions(-) create mode 100644 atlas/helpers.py create mode 100644 atlas/migrations/0048_cidreportanswer_completed_userreportanswer_completed_and_more.py delete mode 100644 atlas/templates/atlas/collection_update_form.html create mode 100755 atlas/templates/atlas/resource_detail.html create mode 100755 atlas/templates/atlas/resource_form.html create mode 100644 atlas/templates/atlas/resource_link_header.html create mode 100755 atlas/templates/atlas/resource_list.html create mode 100644 rad/static/css/forms.css diff --git a/atlas/forms.py b/atlas/forms.py index bf431330..021695ef 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -2,6 +2,7 @@ from django import forms from django.contrib.admin import widgets from django.forms import ( BaseInlineFormSet, + BooleanField, Form, HiddenInput, ModelForm, @@ -54,6 +55,11 @@ import logging from generic.forms import ExamAuthorFormMixin +from crispy_forms.helper import FormHelper + +from atlas.helpers import get_cases_available_to_user + + class ConditionForm(ModelForm): class Meta: model = Condition @@ -75,7 +81,7 @@ class ConditionForm(ModelForm): class CaseCollectionForm(ModelForm): class Meta: model = CaseCollection - exclude = ["cases", "valid_cid_users", "author"] + exclude = ["cases", "valid_cid_users", "valid_user_users", "author"] def __init__(self, *args, **kwargs): self.user = kwargs.pop( @@ -261,6 +267,7 @@ class CaseForm(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 @@ -296,12 +303,18 @@ class CaseForm(ModelForm): # initial["exams"] = [t.pk for t in kwargs["instance"].exams.all()] + ModelForm.__init__(self, *args, **kwargs) super(CaseForm, self).__init__(*args, **kwargs) - # self.fields["series"].queryset = + if self.user.groups.filter(name="atlas_editor").exists(): + case_queryset = Case.objects.all() + else: + case_queryset = get_cases_available_to_user(self.user) + self.fields["previous_case"].queryset=case_queryset + def get_queryset(self, request): return ( super() @@ -401,6 +414,17 @@ CaseDifferentialFormSet = inlineformset_factory( }, ) +class ResourceForm(ModelForm): + class Meta: + model = Resource + exclude = ["author"] + + widgets = { + "description": Textarea(attrs={"cols": 80, "rows": 5}), + "url": TextInput(attrs={"cols": 80, "rows": 1}), + } + + class CaseResourceForm(ModelForm): def __init__(self, *args, user, **kwargs): @@ -417,6 +441,9 @@ class CaseResourceForm(ModelForm): # widget=Select(verbose_name="Series", is_stacked=False), ) + self.fields["pre_review"] = BooleanField(required=False) + + class CaseSeriesForm(ModelForm): def __init__(self, *args, user, **kwargs): @@ -440,10 +467,7 @@ class CaseCollectionCaseForm(ModelForm): def __init__(self, *args, user, **kwargs): super(CaseCollectionCaseForm, self).__init__(*args, **kwargs) - if not user.groups.filter(name="atlas_editor").exists(): - queryset = Case.objects.filter(author__id=user.id) - else: - queryset = Case.objects.all() + queryset = get_cases_available_to_user(user) self.fields["case"] = ModelChoiceField( required=False, @@ -478,7 +502,7 @@ CaseResourceFormSet = inlineformset_factory( form=CaseResourceForm, exclude=[], can_delete=True, - extra=0, + extra=1, max_num=10, ) diff --git a/atlas/helpers.py b/atlas/helpers.py new file mode 100644 index 00000000..06004996 --- /dev/null +++ b/atlas/helpers.py @@ -0,0 +1,10 @@ +from atlas.models import Case + +def get_cases_available_to_user(user): + """Returns a queryset of cases available to the user""" + queryset = Case.objects.filter(author=user) | Case.objects.filter(open_access=True) + return queryset.order_by("pk") + if user.is_superuser: + return Case.objects.all() + else: + return Case.objects.filter(author=user) | Case.objects.filter(open_access=True) \ No newline at end of file diff --git a/atlas/migrations/0048_cidreportanswer_completed_userreportanswer_completed_and_more.py b/atlas/migrations/0048_cidreportanswer_completed_userreportanswer_completed_and_more.py new file mode 100644 index 00000000..836b9605 --- /dev/null +++ b/atlas/migrations/0048_cidreportanswer_completed_userreportanswer_completed_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 5.0.2 on 2024-04-08 12:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0047_caseresource_pre_review_resource_author'), + ] + + operations = [ + migrations.AddField( + model_name='cidreportanswer', + name='completed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userreportanswer', + name='completed', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='case', + name='condition', + field=models.ManyToManyField(blank=True, help_text='The condition(s) the case demonstrates.', to='atlas.condition'), + ), + migrations.AlterField( + model_name='case', + name='diagnostic_certainty', + field=models.IntegerField(choices=[(0, 'None'), (1, 'Possible'), (2, 'Likely'), (3, 'Almost Certain'), (4, 'Certain')], default=0, help_text='The diagnostic certainty of the case.'), + ), + migrations.AlterField( + model_name='case', + name='differential', + field=models.ManyToManyField(help_text='The differential diagnosis for the case.', related_name='casedifferential', through='atlas.Differential', to='atlas.condition'), + ), + migrations.AlterField( + model_name='case', + name='presentation', + field=models.ManyToManyField(blank=True, help_text='The presentation(s) the case is associated with.', to='atlas.presentation'), + ), + migrations.AlterField( + model_name='case', + name='previous_case', + field=models.OneToOneField(blank=True, help_text='If this case is related to another case on the system (e.g. follow up), link them here.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='next_case', to='atlas.case'), + ), + migrations.AlterField( + model_name='case', + name='subspecialty', + field=models.ManyToManyField(blank=True, help_text='The subspecialties the case is associated with. Multiple subspecialties can be selected.', to='atlas.subspecialty'), + ), + migrations.AlterField( + model_name='caseresource', + name='pre_review', + field=models.BooleanField(default=False, help_text='Defines if the resource should be shown pre review (i.e. for pre reading before the case is taken)'), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index 61e601b8..9cb3a26b 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -311,20 +311,32 @@ class Case(models.Model, AuthorMixin, QuestionMixin): # findings = models.TextField(null=True, blank=True) # subspecialty = models.CharField(max_length=2, choices=SubspecialtyChoices.choices) - subspecialty = models.ManyToManyField(Subspecialty, blank=True) + subspecialty = models.ManyToManyField( + Subspecialty, + blank=True, + help_text="The subspecialties the case is associated with. Multiple subspecialties can be selected.", + ) - condition = models.ManyToManyField(Condition, blank=True) + condition = models.ManyToManyField( + Condition, blank=True, help_text="The condition(s) the case demonstrates." + ) - presentation = models.ManyToManyField(Presentation, blank=True) + presentation = models.ManyToManyField( + Presentation, + blank=True, + help_text="The presentation(s) the case is associated with.", + ) pathological_process = models.ManyToManyField(PathologicalProcess, blank=True) differential = models.ManyToManyField( - Condition, through=Differential, related_name="casedifferential" + Condition, through=Differential, related_name="casedifferential", + help_text="The differential diagnosis for the case.", ) diagnostic_certainty = models.IntegerField( - choices=CertaintyChoices.choices, default=CertaintyChoices.NONE + choices=CertaintyChoices.choices, default=CertaintyChoices.NONE, + help_text="The diagnostic certainty of the case.", ) verified = models.BooleanField(default=False) @@ -352,7 +364,9 @@ class Case(models.Model, AuthorMixin, QuestionMixin): help_text="If a case should be freely available to browse", default=True ) - series = models.ManyToManyField("Series", through="SeriesDetail", related_name="case") + series = models.ManyToManyField( + "Series", through="SeriesDetail", related_name="case" + ) notes = GenericRelation(QuestionNote) @@ -362,6 +376,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin): null=True, related_name="next_case", blank=True, + help_text="If this case is related to another case on the system (e.g. follow up), link them here.", ) resource = models.ManyToManyField("Resource", through="CaseResource") @@ -378,12 +393,15 @@ class Case(models.Model, AuthorMixin, QuestionMixin): # return f"{self.pk}: {self.title}" return format_html("{}", self.get_absolute_url(), str(self)) - #def get_base_template(self): + # def get_base_template(self): - #def get_edit_template_links(self): + # def get_edit_template_links(self): def __str__(self): - return f"{self.pk}: {self.title}" + if self.open_access: + return f"{self.pk}: {self.title} [OA]" + else: + return f"{self.pk}: {self.title}" def get_series_blocks(self): html = "" @@ -396,7 +414,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin): examinations = [series.get_examination() for series in self.series.all()] return f"{self.pk}:{self.title} {'/'.join([str(c) for c in self.condition.all()])} [{', '.join(examinations)}]" - def get_case_dicom_json(self, case_title_as_patient_name = True): + def get_case_dicom_json(self, case_title_as_patient_name=True): series_json = [] for series in self.series.all(): series_json.append(series.get_series_dicom_json()) @@ -435,7 +453,6 @@ class Case(models.Model, AuthorMixin, QuestionMixin): return json.dumps(self.get_viva_details()) - def extract_image_dicom_json_from_ds(ds, url, image_index): to_keep = [ "Columns", @@ -597,7 +614,7 @@ class Series(SeriesBase): "{}", self.get_absolute_url(), self.get_full_str() ) - #@lru_cache(maxsize=128) + # @lru_cache(maxsize=128) def get_series_dicom_json(self): instances = [] @@ -662,12 +679,13 @@ class Series(SeriesBase): image = self.images.first() with pydicom.dcmread(image.image) as ds: - date = ds.get("StudyDate", "No date") + date = ds.get("StudyDate", "No date") self.description = f"{date[:4]}-{date[4:6]}-{date[6:]}" self.save() + class CaseCollection(ExamOrCollectionGenericBase): app_name = "atlas" @@ -806,9 +824,13 @@ class CaseCollection(ExamOrCollectionGenericBase): self.VIEWER_MODE_CHOICES.BUILT_IN_WITH_OHIF, ) - def show_ohif_viewer_link(self) -> bool: + def show_ohif_viewer(self) -> bool: return self.viewer_mode in ( self.VIEWER_MODE_CHOICES.OHIF, + ) + + def show_ohif_viewer_link(self) -> bool: + return self.viewer_mode in ( self.VIEWER_MODE_CHOICES.BUILT_IN_WITH_OHIF, ) @@ -843,9 +865,7 @@ class CaseCollection(ExamOrCollectionGenericBase): ) -> Case | Tuple[Case, int]: # Seems to be a bug when case_number is 0 or 1 the same (first) object gets returned # forces a list fixes (but is likely inefficient) - cases = list( - self.get_cases().prefetch_related() - ) + cases = list(self.get_cases().prefetch_related()) try: case = cases[case_index] @@ -859,16 +879,14 @@ class CaseCollection(ExamOrCollectionGenericBase): return case def get_case_take_url(self, case): - cases = list( - self.get_cases().prefetch_related() - ) + cases = list(self.get_cases().prefetch_related()) return reverse( "atlas:collection_case_view_take_user", kwargs={"pk": self.pk, "case_number": cases.index(case)}, ) - def get_ohif_dicom_json(self, case_title_as_patient_name = True): + def get_ohif_dicom_json(self, case_title_as_patient_name=True): studies = [] for n, case in enumerate(self.cases.all()): series_json = [] @@ -880,7 +898,6 @@ class CaseCollection(ExamOrCollectionGenericBase): patient_name = case.title studies.append( - { "StudyInstanceUID": f"1.3.6.1.4.1.14519.5.2.1.6279.6001.{n}", "StudyDate": "20000101", @@ -894,21 +911,16 @@ class CaseCollection(ExamOrCollectionGenericBase): } ) - - - return { - "studies": studies - } + return {"studies": studies} def add_case(self, case): """Adds a case to the collection and makes sure order is maintained""" # We might be better off adding via the case detail model direct - #CaseDetail.objects.create(case=case, collection=self) + # CaseDetail.objects.create(case=case, collection=self) self.cases.add(case) self.order_cases() - - def order_cases(self): + def order_cases(self): """Modifies the casedetail sort_order to sequentially order the cases""" for n, c in enumerate(self.casedetail_set.all().order_by("sort_order")): c.sort_order = n @@ -921,11 +933,15 @@ class SeriesDetail(models.Model): sort_order = models.IntegerField(default=1000) - feedback = models.BooleanField(default=False, help_text="Set to true if the series should only be shown for feedback purposes.") + feedback = models.BooleanField( + default=False, + help_text="Set to true if the series should only be shown for feedback purposes.", + ) class Meta: ordering = ("sort_order",) + class CaseDetail(models.Model): case = models.ForeignKey(Case, on_delete=models.CASCADE) collection = models.ForeignKey(CaseCollection, on_delete=models.CASCADE) @@ -950,6 +966,8 @@ class BaseReportAnswer(models.Model): blank=True, null=True, validators=[MaxValueValidator(10), MinValueValidator(0)] ) + completed = models.BooleanField(default=False) + def save(self, *args, **kwargs): self.clean() return super(BaseReportAnswer, self).save(*args, **kwargs) @@ -1145,6 +1163,7 @@ class UncategorisedDicom(models.Model): class DuplicateDicom(Exception): pass + class Resource(models.Model, AuthorMixin): name = models.CharField(max_length=255) description = models.TextField(blank=True, null=True) @@ -1166,11 +1185,26 @@ class Resource(models.Model, AuthorMixin): def get_link(self): return format_html("{}", self.get_absolute_url(), self.name) + def get_display(self) -> str: + if self.url: + html = f"{self.name}" + elif self.file: + html = f"{self.name}" + else: + html = self.name + + return format_html(html) + + + class CaseResource(models.Model): resource = models.ForeignKey(Resource, on_delete=models.CASCADE) case = models.ForeignKey(Case, on_delete=models.CASCADE) - pre_review = models.BooleanField(default=False) + pre_review = models.BooleanField( + default=False, + help_text="Defines if the resource should be shown pre review (i.e. for pre reading before the case is taken)", + ) def __str__(self) -> str: - return f"{self.resource} - {self.case}" \ No newline at end of file + return f"{self.resource} - {self.case} (Pre: {self.pre_review})" diff --git a/atlas/templates/atlas/add_case_to_collection.html b/atlas/templates/atlas/add_case_to_collection.html index eab95ab0..ff10f5cb 100644 --- a/atlas/templates/atlas/add_case_to_collection.html +++ b/atlas/templates/atlas/add_case_to_collection.html @@ -1,7 +1,7 @@
{% csrf_token %} - + - - Popup - - - +

Report: {{ case.report }}

+

Resource
{% for caseresource in case.caseresource_set.all %} + {{caseresource.resource.name}} + {% if caseresource.pre_review %} + (pre) + {% else %} + (post) + {% endif %} {% endfor %} -

- - Add new series
- Import new uploads
-
- + - - - -
- -
+

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

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

-

- Condition: -

    - {% for con in case.condition.all %} -
  • {{con.get_link}}
  • - {% endfor %} -
-
-
- Findings - {% if case.series.all %} - {% for series in case.series.all %} - {% for finding in series.findings.all %} -
- - Series {{series.pk}}: {{series.get_examination_full}} |  - - {% if finding.findings.all %} +
+

+ Condition: +

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

+ {% else %} + No series associated with case. + {% endif %} + +
+
+
+ Differentials +
    + {% for diff in case.differentialcase.all %} +
  • {{diff.condition.get_link}} - {{diff.text}}
  • + {% endfor %} +
+
+
+

+ + Subspecialty: + + Pathological Process: + + Diagnostic certainty: {{case.get_diagnostic_certainty_display}}
+ Collections: + +
+ +

+

Previous case: {{ case.previous_case.get_link }}

+

Next case: {{ case.next_case.get_link }}

+
+ {% include 'question_notes.html' %} +
- Subspecialty: - - Pathological Process: - - Diagnostic certainty: {{case.get_diagnostic_certainty_display}}
- Collections: - -
- -

-

Previous case: {{ case.previous_case.get_link }}

-

Next case: {{ case.next_case.get_link }}

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

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

-

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

- -

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

+

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

diff --git a/atlas/templates/atlas/case_form.html b/atlas/templates/atlas/case_form.html index f3e9d502..a1fbeb0a 100755 --- a/atlas/templates/atlas/case_form.html +++ b/atlas/templates/atlas/case_form.html @@ -29,18 +29,18 @@ function add_series_input_form() { var form_idx = $('#id_Case_series-TOTAL_FORMS').val(); - $('#series_formset').append($('#empty_series_form').html().replace(/__prefix__/g, form_idx)); + $('#series_formset ol').append($('#empty_series_form').html().replace(/__prefix__/g, form_idx)); $('#id_Case_series-TOTAL_FORMS').val(parseInt(form_idx) + 1); } function add_differential_input_form() { var form_idx = $('#id_differentialcase-TOTAL_FORMS').val(); - $('#casedifferential_formset').append($('#empty_casedifferential_form').html().replace(/__prefix__/g, form_idx)); + $('#casedifferential_formset ol').append($('#empty_casedifferential_form').html().replace(/__prefix__/g, form_idx)); $('#id_differentialcase-TOTAL_FORMS').val(parseInt(form_idx) + 1); } function add_resource_input_form() { var form_idx = $('#id_resourcecase-TOTAL_FORMS').val(); - $('#caseresource_formset').append($('#empty_caseresource_form').html().replace(/__prefix__/g, form_idx)); + $('#caseresource_formset ol').append($('#empty_caseresource_form').html().replace(/__prefix__/g, form_idx)); $('#id_resourcecase-TOTAL_FORMS').val(parseInt(form_idx) + 1); } @@ -60,72 +60,82 @@ {% endif %}

Submit Case

- Use this form to create a atlas case. Existing associated image sets can be added using this form. +
+ Instructions +

Use this form to create a atlas case

+

Existing associated image sets can be added using this form.

+

The more information you provide, the more useful the case will be to others.

+
{% if form.collection %} {% endif %} +
{% csrf_token %} - - {{ form.as_table }} -
+ {{ form }}

Series:

Add image sets here. These can only be added once created (they can also be added to cases on creation). -
+
+
    {% for form in series_formset %} -
      +
    • {{form.non_field_errors}} {{form.errors}} - {{ form.as_ul }} -
    + {{ form }} + {% endfor %} +
{{ series_formset.management_form }}

Differential:

Add differential here. -
+
+
    {% for form in casedifferential_formset %} -
      +
    • {{form.non_field_errors}} {{form.errors}} - {{ form.as_ul }} -
    + {{ form }} + {% endfor %} +
{{ casedifferential_formset.management_form }}

Resource:

- Add resource here. + Add resource here. Resource can be anything that is useful for the case (videos, pdf, etc). This can either be avalaible before attempting the case (such a for a pre reading list) or after. -
+
+
    {% for form in caseresource_formset %} -
      +
    • {{form.non_field_errors}} {{form.errors}} - {{ form.as_ul }} -
    + {{ form }} + {% endfor %} +
{{ caseresource_formset.management_form }}
{% endblock %} diff --git a/atlas/templates/atlas/casecollection_form.html b/atlas/templates/atlas/casecollection_form.html index e1e43597..4b926578 100755 --- a/atlas/templates/atlas/casecollection_form.html +++ b/atlas/templates/atlas/casecollection_form.html @@ -1,4 +1,4 @@ -{% extends "atlas/base.html" %} +{% extends "atlas/exams.html" %} {% block css %} @@ -10,7 +10,7 @@ + + +{% endblock %} +{% block content %} + + +

Edit Resource {{object.name}}

+
+ {% csrf_token %} + {{ form|crispy }} + +
+{% endblock %} \ No newline at end of file diff --git a/atlas/templates/atlas/resource_link_header.html b/atlas/templates/atlas/resource_link_header.html new file mode 100644 index 00000000..b2cc6334 --- /dev/null +++ b/atlas/templates/atlas/resource_link_header.html @@ -0,0 +1,10 @@ +
+ + + Edit + Delete + {% if request.user.is_superuser %} + Admin Edit + {% endif %} + +
diff --git a/atlas/templates/atlas/resource_list.html b/atlas/templates/atlas/resource_list.html new file mode 100755 index 00000000..7a4747f2 --- /dev/null +++ b/atlas/templates/atlas/resource_list.html @@ -0,0 +1,21 @@ +{% extends 'atlas/base.html' %} +{% block content %} +Create a new resource + +

Resources

+ +
    + {% for resource in object_list %} +
  • + {{resource}} +
  • + {% endfor %} +
+ +{% endblock %} + +{% block js %} + +{% endblock %} \ No newline at end of file diff --git a/atlas/templates/atlas/selfreview_form.html b/atlas/templates/atlas/selfreview_form.html index 899a77ae..a5a2d093 100644 --- a/atlas/templates/atlas/selfreview_form.html +++ b/atlas/templates/atlas/selfreview_form.html @@ -12,7 +12,7 @@ {% csrf_token %} {{ form.non_field_errors }} - {{ form.as_p }} + {{ form }} {% comment %}
{{ form.content_type.errors }} {{ form.content_type }} diff --git a/atlas/urls.py b/atlas/urls.py index f37c2467..bc856648 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -42,6 +42,15 @@ urlpatterns = [ path("uploads/case/", views.user_uploads, name="user_uploads_case"), path("uploads//user/case/", views.other_user_uploads, name="other_user_uploads_case"), path("uploads/series_id/", views.user_uploads_series, name="user_uploads_series"), + path("resource/", views.ResourceView.as_view(), name="resource_view"), + path("resource//", views.resource_detail, name="resource_detail"), + path("resource//update", views.ResourceUpdate.as_view(), name="resource_update"), + path("resource//delete", views.ResourceDelete.as_view(), name="resource_delete"), + path( + "resource/create", + views.ResourceCreate.as_view(), + name="resource_create", + ), path( "collection/create", views.CaseCollectionCreate.as_view(), @@ -74,6 +83,21 @@ urlpatterns = [ views.GenericExamViews.exam_cids, name="exam_cids", ), + path( + "collection//user_status", + views.GenericExamViews.exam_user_status, + name="exam_user_status", + ), + path( + "collection//user_status//cid", + views.GenericExamViews.exam_user_status_cid, + name="exam_user_status_cid", + ), + path( + "collection//user_status//user", + views.GenericExamViews.exam_user_status_user, + name="exam_user_status_user", + ), path( "exam//cids/edit", views.GenericExamViews.exam_cids_edit, diff --git a/atlas/views.py b/atlas/views.py index 8d8efb3c..519b5e2d 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -31,6 +31,7 @@ from django.http import Http404, JsonResponse from django.http import HttpResponseRedirect, HttpResponse from generic.models import CidUser, CidUserExam +from atlas.helpers import get_cases_available_to_user from .forms import ( AddCollectionToCaseForm, @@ -45,6 +46,7 @@ from .forms import ( ConditionAutocompleteForm, ConditionForm, FindingForm, + ResourceForm, SelfReviewForm, SeriesForm, SeriesImageFormSet, @@ -63,6 +65,7 @@ from .models import ( Differential, PathologicalProcess, Presentation, + Resource, SelfReview, Series, Examination, @@ -127,7 +130,12 @@ import logging from copy import deepcopy from django.forms.models import model_to_dict -from generic.views import AuthorRequiredMixin, ExamCloneMixin, ExamViews, SeriesImagesZipViewBase +from generic.views import ( + AuthorRequiredMixin, + ExamCloneMixin, + ExamViews, + SeriesImagesZipViewBase, +) from reversion.views import RevisionMixin import reversion @@ -139,14 +147,6 @@ import difflib logger = logging.getLogger(__name__) -def get_cases_available_to_user(user): - """Returns a queryset of cases available to the user""" - return Case.objects.filter(author=user) | Case.objects.filter(open_access=True) - if user.is_superuser: - return Case.objects.all() - else: - return Case.objects.filter(author=user) | Case.objects.filter(open_access=True) - class AuthorOrCheckerRequiredMixin(object): def get_object(self, *args, **kwargs): @@ -160,6 +160,7 @@ class AuthorOrCheckerRequiredMixin(object): raise PermissionDenied() # or Http404 return obj + class AtlasEditorRequiredMixin(object): def get_object(self, *args, **kwargs): obj = super().get_object(*args, **kwargs) @@ -170,6 +171,7 @@ class AtlasEditorRequiredMixin(object): return obj raise PermissionDenied("You must be an atlas editor to do this.") # or Http404 + @login_required @user_has_case_view_access def case_detail(request, pk): @@ -208,7 +210,7 @@ def series_detail(request, pk, finding_pk=None): @login_required -#@user_is_atlas_editor +# @user_is_atlas_editor def condition_detail(request, pk): condition = get_object_or_404(Condition, pk=pk) @@ -216,7 +218,10 @@ def condition_detail(request, pk): can_merge = False - if request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists(): + if ( + request.user.is_superuser + or request.user.groups.filter(name="atlas_editor").exists() + ): can_merge = True # logging.debug(atlas.subspecialty.first().name.all()) @@ -275,7 +280,7 @@ def condition_merge(request, pk_to_merge): @login_required -#@user_is_atlas_editor +# @user_is_atlas_editor def subspecialty_detail(request, pk): subspecialty = get_object_or_404(Subspecialty, pk=pk) @@ -290,7 +295,7 @@ def subspecialty_detail(request, pk): @login_required -#@user_is_atlas_editor +# @user_is_atlas_editor def presentation_detail(request, pk): presentation = get_object_or_404(Presentation, pk=pk) @@ -305,7 +310,7 @@ def presentation_detail(request, pk): @login_required -#@user_is_atlas_editor +# @user_is_atlas_editor def pathological_process_detail(request, pk): pathological_process = get_object_or_404(PathologicalProcess, pk=pk) @@ -320,7 +325,7 @@ def pathological_process_detail(request, pk): @login_required -#@user_is_atlas_editor +# @user_is_atlas_editor def structure_detail(request, pk): structure = get_object_or_404(Structure, pk=pk) @@ -335,7 +340,7 @@ def structure_detail(request, pk): @login_required -#@user_is_atlas_editor +# @user_is_atlas_editor def finding_detail(request, pk): finding = get_object_or_404(Finding, pk=pk) @@ -349,6 +354,13 @@ def finding_detail(request, pk): ) +@login_required +def resource_detail(request, pk): + resource = get_object_or_404(Resource, pk=pk) + + return render(request, "atlas/resource_detail.html", {"resource": resource}) + + @login_required def author_detail(request, pk): # logging.debug(Author.objects.all()) @@ -381,11 +393,11 @@ def user_collections(request): return render(request, "atlas/user_collections.html", {"collections": collections}) + def add_case_to_collection(request, collection_id): if not request.htmx: return Http404 - collection = get_object_or_404(CaseCollection, pk=collection_id) if request.method == "POST": @@ -395,14 +407,18 @@ def add_case_to_collection(request, collection_id): return HttpResponse("Case already in collection") print(case) collection.add_case(case) - return HttpResponse("Case added to collection") + return HttpResponse("Case added to collection (refresh to see)") - - #if request.method == "POST": + # if request.method == "POST": cases = get_cases_available_to_user(request.user) - return render(request, "atlas/add_case_to_collection.html", {"cases": cases, "collection": collection}) + return render( + request, + "atlas/add_case_to_collection.html", + {"cases": cases, "collection": collection}, + ) + @login_required def user_uploads_series(request, series_instance_uid: str): @@ -418,17 +434,25 @@ def user_uploads_series(request, series_instance_uid: str): {"dicoms": dicoms, "series_id": series_instance_uid}, ) + @login_required @user_passes_test(lambda u: u.is_superuser) def all_uploads(request, case_id: int | None = None): return user_uploads(request, case_id, user_only=False) + @user_passes_test(lambda u: u.is_superuser) def other_user_uploads(request, user_pk: int, case_id: int | None = None): return user_uploads(request, case_id, user_only=False, user_pk=user_pk) + @login_required -def user_uploads(request, case_id: int | None = None, user_only: bool = True, user_pk: int | None = None): +def user_uploads( + request, + case_id: int | None = None, + user_only: bool = True, + user_pk: int | None = None, +): user = request.user if user_only: dicoms = UncategorisedDicom.objects.filter(user=request.user) @@ -446,7 +470,6 @@ def user_uploads(request, case_id: int | None = None, user_only: bool = True, us d.save() tags = d.basic_dicom_tags - data[tags["SeriesInstanceUID"]].append((tags, d.created_date)) series_list = [] @@ -461,7 +484,9 @@ def user_uploads(request, case_id: int | None = None, user_only: bool = True, us case = get_object_or_404(Case, pk=case_id) return render( - request, "atlas/user_uploads.html", {"series_list": series_list, "case": case, "user": user} + request, + "atlas/user_uploads.html", + {"series_list": series_list, "case": case, "user": user}, ) @@ -513,9 +538,34 @@ class StructureDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView): success_url = reverse_lazy("atlas:structure_view") +class ResourceDelete(LoginRequiredMixin, DeleteView): + model = Resource + template_name = "confirm_delete.html" + + success_url = reverse_lazy("atlas:resource_view") + + +class ResourceCreate(LoginRequiredMixin, CreateView): + model = Resource + form_class = ResourceForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + form.instance.author.add(self.request.user.id) + + return super().form_valid(form) + + +class ResourceView(LoginRequiredMixin, ListView): + model = Resource + # form_class = ResourceForm + + class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView): model = CaseCollection form_class = CaseCollectionForm + template_name = "atlas/casecollection_form.html" def get_initial(self): # print(self.request) @@ -553,7 +603,6 @@ class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView): self.object.save() form.instance.author.add(self.request.user.id) - context = self.get_context_data(form=form) case_formset = context["case_formset"] if case_formset.is_valid(): @@ -618,7 +667,7 @@ class SeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView): class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView): model = CaseCollection form_class = CaseCollectionForm - template_name = "atlas/collection_update_form.html" + template_name = "atlas/casecollection_form.html" # fields = '__all__' # #fields = [ 'condition' ] @@ -724,6 +773,18 @@ class SeriesUpdate( return super().form_invalid(form) +class ResourceUpdate(LoginRequiredMixin, UpdateView): + model = Resource + form_class = ResourceForm + + def form_valid(self, form): + self.object = form.save(commit=False) + self.object.save() + form.instance.author.add(self.request.user.id) + + return super().form_valid(form) + + class ConditionUpdate(RevisionMixin, LoginRequiredMixin, UpdateView): model = Condition form_class = ConditionForm @@ -775,7 +836,8 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin): self.request.POST, self.request.FILES ) context["caseresource_formset"] = CaseResourceFormSet( - self.request.POST, self.request.FILES, + self.request.POST, + self.request.FILES, form_kwargs={"user": self.request.user}, ) context["series_formset"].full_clean() @@ -805,7 +867,11 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin): series_formset = context["series_formset"] casedifferential_formset = context["casedifferential_formset"] caseresource_formset = context["caseresource_formset"] - if series_formset.is_valid() and casedifferential_formset.is_valid() and caseresource_formset.is_valid(): + if ( + series_formset.is_valid() + and casedifferential_formset.is_valid() + and caseresource_formset.is_valid() + ): response = super().form_valid(form) series_formset.instance = self.object series_formset.save() @@ -871,8 +937,15 @@ class AtlasUpdate( context["casedifferential_formset"] = CaseDifferentialFormSet( self.request.POST, self.request.FILES, instance=self.object ) + context["caseresource_formset"] = CaseResourceFormSet( + self.request.POST, + self.request.FILES, + instance=self.object, + form_kwargs={"user": self.request.user}, + ) context["series_formset"].full_clean() context["casedifferential_formset"].full_clean() + context["caseresource_formset"].full_clean() else: context["series_formset"] = SeriesFormSet( instance=self.object, form_kwargs={"user": self.request.user} @@ -880,6 +953,10 @@ class AtlasUpdate( context["casedifferential_formset"] = CaseDifferentialFormSet( instance=self.object ) + context["caseresource_formset"] = CaseResourceFormSet( + instance=self.object, + form_kwargs={"user": self.request.user}, + ) return context def form_valid(self, form): @@ -891,13 +968,20 @@ class AtlasUpdate( context = self.get_context_data(form=form) series_formset = context["series_formset"] casedifferential_formset = context["casedifferential_formset"] + caseresource_formset = context["caseresource_formset"] # logger.debug(formset.is_valid()) - if series_formset.is_valid() and casedifferential_formset.is_valid(): + if ( + series_formset.is_valid() + and casedifferential_formset.is_valid() + and caseresource_formset.is_valid() + ): response = super().form_valid(form) series_formset.instance = self.object series_formset.save() casedifferential_formset.instance = self.object casedifferential_formset.save() + caseresource_formset.instance = self.object + caseresource_formset.save() return response else: @@ -970,7 +1054,6 @@ class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView): return initial_data - @login_required @user_is_author_or_atlas_editor def atlas_scrap(request, pk): @@ -1069,6 +1152,7 @@ class FindingView(LoginRequiredMixin, SingleTableMixin, FilterView): filterset_class = FindingFilter + @login_required def case_order_dicom(request, pk): if request.htmx: @@ -1086,11 +1170,12 @@ def case_order_dicom(request, pk): fail.append(series.pk) if fail: - return HttpResponse(f"Unable to order series {','.join([str(i) for i in fail])}") + return HttpResponse( + f"Unable to order series {','.join([str(i) for i in fail])}" + ) else: return HttpResponse("Series reordered") - raise Http404 @@ -1105,6 +1190,7 @@ def series_order_dicom(request, pk): return redirect("atlas:series_detail", pk=pk) + @login_required def image_diff(request): if request.htmx: @@ -1116,23 +1202,23 @@ def image_diff(request): second: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[1]) first_ds = pydicom.read_file(first.image.file) - #del first_ds.PixelData - #j1 = first_ds.to_json_dict() + # del first_ds.PixelData + # j1 = first_ds.to_json_dict() f = print_dicom(first_ds, join=False) second_ds = pydicom.read_file(second.image.file) - #del second_ds.PixelData - #j2 = second_ds.to_json_dict() + # del second_ds.PixelData + # j2 = second_ds.to_json_dict() s = print_dicom(second_ds, join=False) diff = difflib.HtmlDiff().make_table(f, s) - + dict_diff = pprint.pformat(compare_dicom_datasets(first_ds, second_ds)) - #return HttpResponse(diff) + # return HttpResponse(diff) return HttpResponse(f"
{dict_diff}

{diff}") - + else: ds_set = [] for pk in image_ids: @@ -1142,7 +1228,6 @@ def image_diff(request): dict_diff = pprint.pformat(compare_dicom_datasets(*ds_set)) return HttpResponse(f"
{dict_diff}
") - return HttpResponse("Fail") @@ -1366,6 +1451,7 @@ class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView): filterset_class = CaseCollectionFilter + @user_is_collection_author_or_atlas_editor def collection_viva(request, pk): collection = get_object_or_404(CaseCollection, pk=pk) @@ -1378,11 +1464,12 @@ def collection_viva(request, pk): {"collection": collection, "cases": cases, "can_edit": True}, ) + @user_is_collection_author_or_atlas_editor def collection_viva_case(request, pk, case_number): collection = get_object_or_404(CaseCollection, pk=pk) - #cases = collection.cases.all().order_by("casedetail__sort_order") + # cases = collection.cases.all().order_by("casedetail__sort_order") current_case = collection.get_case_by_index(case_number) return render( @@ -1391,6 +1478,7 @@ def collection_viva_case(request, pk, case_number): {"collection": collection, "current_case": current_case}, ) + @user_is_collection_author_or_atlas_editor def collection_viva_series(request, pk, series_id): collection = get_object_or_404(CaseCollection, pk=pk) @@ -1408,8 +1496,10 @@ def collection_viva_series(request, pk, series_id): def collection_detail(request, pk): collection = get_object_or_404(CaseCollection, pk=pk) - #cases = collection.cases.all().order_by("casedetail__sort_order") - casedetails = CaseDetail.objects.filter(collection=collection).order_by("sort_order") + # cases = collection.cases.all().order_by("casedetail__sort_order") + casedetails = CaseDetail.objects.filter(collection=collection).order_by( + "sort_order" + ) return render( request, @@ -1725,12 +1815,20 @@ def collection_case_view_take( else: form = ReportAnswerForm(request.POST) - if not cid_user_exam.completed: + if not cid_user_exam.completed and not ( + answer is not None and answer.completed + ): if form.is_valid(): answer = form.save(commit=False) answer.set_cid_or_user(cid=cid, user=request.user) answer.question = case_detail # answer.published_date = timezone.now() + + if "complete_case" in request.POST: + if not collection.self_review: + raise Http404("Self review not enabled") + answer.completed = True + answer.save() cid_user_exam.end_time = timezone.now() @@ -1769,7 +1867,11 @@ def collection_case_view_take( # Set what details a user is / is not able to view when taking the case # this can be configured via the collection edit menu - if collection.publish_results or cid_user_exam.completed: + if ( + collection.publish_results + or cid_user_exam.completed + or (answer is not None and answer.completed) + ): completed = True show_title = collection.show_title_post show_history = collection.show_history_post @@ -1778,6 +1880,8 @@ def collection_case_view_take( show_report = collection.show_report_post self_review = cid_user_exam.selfreview_set.filter(case=case) + + resources = case.caseresource_set.all() else: completed = False show_title = collection.show_title_pre @@ -1787,6 +1891,8 @@ def collection_case_view_take( show_report = collection.show_report_pre self_review = [] + resources = case.caseresource_set.filter(pre_review=True) + return render( request, "atlas/collection_case_view_take.html", @@ -1806,6 +1912,7 @@ def collection_case_view_take( "show_description": show_description, "show_discussion": show_discussion, "show_report": show_report, + "resources": resources, "cid_user_exam": cid_user_exam, "completed": completed, "self_review": self_review, @@ -1856,11 +1963,13 @@ def collection_case_view(request, pk, case_number): }, ) + def collection_dicom_json(request, pk): collection = get_object_or_404(CaseCollection, pk=pk) return JsonResponse(collection.get_ohif_dicom_json()) + @user_is_author_or_atlas_editor def use_dates_as_descriptions(request, pk): case = get_object_or_404(Case, pk=pk) @@ -1871,16 +1980,19 @@ def use_dates_as_descriptions(request, pk): return HttpResponse("Done") + def case_dicom_json(request, pk): case = get_object_or_404(Case, pk=pk) return JsonResponse(case.get_case_dicom_json()) + def series_dicom_json(request, pk): series = get_object_or_404(Series, pk=pk) return JsonResponse(series.get_ohif_dicom_json()) + @user_is_collection_author_or_atlas_editor def delete_collection_cid_answers(request, exam_id, cid): collection = get_object_or_404(CaseCollection, pk=exam_id) @@ -2227,7 +2339,7 @@ def combine_series(request): it will not migrate other models such as SeriesFinding or other details """ series_ids = request.POST.getlist("series-ids") - #series = get_object_or_404(Series, pk=series_id) + # series = get_object_or_404(Series, pk=series_id) if len(series_ids) < 2: return HttpResponse("Not enough series to combine") @@ -2252,7 +2364,8 @@ def combine_series(request): series.delete() return HttpResponse(f"Series {series_ids} combines") - #return HttpResponse("Fail") + # return HttpResponse("Fail") + class CaseCollectionAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView): model = CaseCollection @@ -2264,6 +2377,7 @@ class CaseCollectionAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView) context["collection"] = context["object"] return context + class CaseAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView): model = Case form_class = CaseAuthorForm @@ -2272,4 +2386,4 @@ class CaseAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView): def get_context_data(self, **kwargs): context = super(CaseAuthorUpdate, self).get_context_data(**kwargs) context["collection"] = context["object"] - return context \ No newline at end of file + return context diff --git a/rad/static/css/anatomy.css b/rad/static/css/anatomy.css index d3cef32d..f74c681d 100644 --- a/rad/static/css/anatomy.css +++ b/rad/static/css/anatomy.css @@ -837,41 +837,6 @@ input { color: gray; } -.select2-dropdown { - background-color: #05668d; - color: white; - border-color: #05668d; -} - -.select2-selection .select2-selection--multiple, -.select2 .select2-selection--single, -.select2-selection { - background-color: #05668d; - color: white; - /* border: 1px solid #aaa; */ - border-radius: 0px; -} - -.select2-results__option { - background-color: #05668d; -} - -.select2-search__field { - background-color: #05668d; - color: darkblue; - border: 1px solid black; - -} - -.select2-container--default.select2-container--focus .select2-selection--multiple, -.select2-container--default.select2-container--default .select2-selection--multiple, -.select2-selection .select2-selection__rendered .select2-selection__choice { - background-color: #5897fb; -} - -.select2-selection__rendered { - user-select: none; -} .temp-thumb-large { width: 1000px; diff --git a/rad/static/css/forms.css b/rad/static/css/forms.css new file mode 100644 index 00000000..59e95467 --- /dev/null +++ b/rad/static/css/forms.css @@ -0,0 +1,89 @@ +form .helptext { + color: #666; + float: right; + opacity: 0.5; +} + +form>div:hover .helptext { + color: #666; + opacity: 1; +} + +form > div { + margin: 1px; +} + +form > div:hover { + border: 1px solid #666; + margin: -1px; +} + + +form>div>label { + display: block; + margin-top: 0.5em; + font-size: 1.5em; +} + +form label, +form legend { + user-select: none; +} + +form>div>fieldset>legend { + margin-top: 0.5em; +} + +form input:required { + border: 1px darkblue dashed; +} + +.list_formset li { + border-bottom: 1px gray dotted; +} + +.select2-dropdown { + background-color: #05668d; + color: white; + border-color: #05668d; +} + +.select2-selection .select2-selection--multiple, +.select2 .select2-selection--single, +.select2-selection { + background-color: #05668d; + background-color: #5897fb; + color: white; + /* border: 1px solid #aaa; */ + border-radius: 2px; +} + +.select2-results__option { + background-color: #05668d; +} + +.list_formset li { + border-bottom: 1px gray dotted; +} + +.list_formset li:has(input[name*='DELETE']:checked) { + border: 1px dashed red; + opacity: 0.5; +} + +.select2-search__field { + background-color: #05668d; + color: darkblue; + border: 1px solid black; + +} + +.select2-container--default.select2-container--focus .select2-selection--multiple, +.select2-container--default.select2-container--default .select2-selection--multiple, +.select2-selection .select2-selection__rendered .select2-selection__choice { + background-color: #5897fb; +} + +.select2-selection__rendered { + user-select: none; +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index e791d397..4356c0d7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -71,6 +71,7 @@ {% block js %} {% endblock %} + {% block css %} {% endblock %} diff --git a/templates/exam_create_form.html b/templates/exam_create_form.html index 533f1e65..bcd12a6f 100644 --- a/templates/exam_create_form.html +++ b/templates/exam_create_form.html @@ -10,7 +10,7 @@
{% csrf_token %} - {{ form.as_table }} + {{ form }}