55fcf71431
- Implemented refresh buttons for each exam/packet section and a global refresh button. - Enhanced resource management in the atlas by allowing inline creation and attachment of resources. - Redirected users back to the case within a case collection upon saving or cancelling edits. - Optimized the case series update view to prevent hanging issues on production. - Fixed an issue with the local-up script not shutting down running containers properly. Add marker feedback field to useranswer model - Created migrations to add a 'marker_feedback' field to the 'useranswer' model in both 'longs' and 'shorts' apps. - The new field is a TextField that allows optional feedback for the marker, which will not be shown to the candidate.
1593 lines
53 KiB
Python
Executable File
1593 lines
53 KiB
Python
Executable File
from django import forms
|
|
from django.contrib.admin import widgets
|
|
from django.forms import (
|
|
BaseInlineFormSet,
|
|
BooleanField,
|
|
Form,
|
|
HiddenInput,
|
|
ModelForm,
|
|
ModelMultipleChoiceField,
|
|
ModelChoiceField,
|
|
ChoiceField,
|
|
CharField,
|
|
ValidationError,
|
|
modelformset_factory,
|
|
CheckboxSelectMultiple,
|
|
SplitDateTimeWidget,
|
|
)
|
|
from django.utils.html import escape
|
|
from django.forms import inlineformset_factory
|
|
from django.shortcuts import get_object_or_404
|
|
from django_jsonforms.forms import JSONSchemaField
|
|
from django_svelte_jsoneditor.widgets import SvelteJSONEditorWidget
|
|
|
|
|
|
from atlas.models import (
|
|
Case,
|
|
CaseCollection,
|
|
CaseDetail,
|
|
CasePrior,
|
|
CidReportAnswer,
|
|
Differential,
|
|
Finding,
|
|
QuestionSchema,
|
|
Resource,
|
|
SelfReview,
|
|
Series,
|
|
SeriesDetail,
|
|
SeriesImage,
|
|
SeriesFinding,
|
|
Condition,
|
|
Structure,
|
|
Subspecialty,
|
|
Presentation,
|
|
PathologicalProcess,
|
|
Procedure,
|
|
UncategorisedDicom,
|
|
UserReportAnswer,
|
|
CaseDisplaySet,
|
|
CaseReviewMessage,
|
|
)
|
|
from .models import NormalCase
|
|
|
|
from anatomy.models import Modality
|
|
|
|
from generic.models import Examination # , Sign
|
|
|
|
# from generic.models import Examination, Site, Condition, Sign
|
|
|
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
|
|
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML, Button
|
|
from django.urls import reverse
|
|
from crispy_forms.bootstrap import Accordion, AccordionGroup
|
|
|
|
from tinymce.widgets import TinyMCE
|
|
|
|
from dal import autocomplete
|
|
|
|
from autocomplete import (
|
|
widgets as htmx_widgets,
|
|
Autocomplete,
|
|
AutocompleteWidget,
|
|
ModelAutocomplete,
|
|
register as autocomplete_register,
|
|
)
|
|
|
|
from loguru import logger
|
|
|
|
|
|
from generic.forms import (
|
|
ExamAuthorFormMixin,
|
|
ExamGroupsFormMixin,
|
|
SplitDateTimeFieldDefaultTime,
|
|
SplitDateTimeFieldDefaultTimeEnd,
|
|
)
|
|
|
|
from crispy_forms.helper import FormHelper
|
|
|
|
from atlas.helpers import get_cases_available_to_user
|
|
|
|
from typing import Any
|
|
|
|
from helpers.cimar import CimarAPI, NotFoundError
|
|
from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD
|
|
|
|
|
|
class ClearableInput(TextInput):
|
|
template_name = "crispy_forms/widgets/clearable_input_single_line.html"
|
|
|
|
|
|
class ClearableTextarea(Textarea):
|
|
template_name = "crispy_forms/widgets/clearable_input.html"
|
|
|
|
|
|
class CaseSelect(Select):
|
|
template_name = "atlas/case_select_widget.html"
|
|
|
|
def create_option(self, *args, **kwargs):
|
|
option = super().create_option(*args, **kwargs)
|
|
|
|
return option
|
|
|
|
def get_context(self, name: str, value, attrs) -> dict[str, Any]:
|
|
context = super().get_context(name, value, attrs)
|
|
return context
|
|
|
|
pass
|
|
|
|
|
|
class MoveSelectedSeriesForm(Form):
|
|
destination_case = ModelChoiceField(
|
|
queryset=Case.objects.none(),
|
|
widget=HiddenInput(),
|
|
label="Move selected series to case",
|
|
required=True,
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
user = kwargs.pop("user", None)
|
|
source_case = kwargs.pop("source_case", None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
available_cases = get_cases_available_to_user(user)
|
|
if source_case is not None:
|
|
available_cases = available_cases.exclude(pk=source_case.pk)
|
|
|
|
self.fields["destination_case"].queryset = available_cases.order_by("pk")
|
|
|
|
|
|
class ConditionForm(ModelForm):
|
|
class Meta:
|
|
model = Condition
|
|
exclude = ["rcr_curriculum"]
|
|
widgets = {
|
|
# Use an AJAX/autocomplete single-select for canonical so users
|
|
# can search for existing canonical Conditions by name.
|
|
"canonical": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
|
|
"parent": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:condition-autocomplete"
|
|
),
|
|
"rcr_curriculum_map": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:condition-autocomplete-rcr-curriculum"
|
|
),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# Crispy helper to render a submit button consistently
|
|
try:
|
|
self.helper = FormHelper()
|
|
self.helper.form_method = "post"
|
|
self.helper.form_tag = True
|
|
# Primary submit
|
|
self.helper.add_input(Submit("submit", "Submit"))
|
|
# Cancel as a button that redirects back to the condition list
|
|
try:
|
|
cancel_url = reverse("atlas:condition_view")
|
|
except Exception:
|
|
cancel_url = "#"
|
|
self.helper.add_input(
|
|
Button(
|
|
"cancel",
|
|
"Cancel",
|
|
css_class="btn btn-secondary ms-2",
|
|
onclick=f"window.location='{cancel_url}'",
|
|
)
|
|
)
|
|
except Exception:
|
|
# If crispy not available or something goes wrong, don't break form
|
|
pass
|
|
|
|
|
|
class ConditionRelationshipForm(Form):
|
|
parents = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=Condition.objects.none(),
|
|
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
|
|
)
|
|
children = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=Condition.objects.none(),
|
|
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
|
|
)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.instance = kwargs.pop("instance", None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
condition_qs = Condition.objects.all().order_by("name")
|
|
if self.instance is not None and getattr(self.instance, "pk", None):
|
|
condition_qs = condition_qs.exclude(pk=self.instance.pk)
|
|
|
|
self.fields["parents"].queryset = condition_qs
|
|
self.fields["children"].queryset = condition_qs
|
|
|
|
self.helper = FormHelper()
|
|
self.helper.form_method = "post"
|
|
self.helper.form_tag = False
|
|
self.helper.layout = Layout(
|
|
Fieldset(
|
|
"Condition relationships",
|
|
"parents",
|
|
"children",
|
|
)
|
|
)
|
|
|
|
def save(self, commit=True):
|
|
if self.instance is None:
|
|
raise ValueError("ConditionRelationshipForm requires an instance")
|
|
|
|
parents = list(self.cleaned_data.get("parents", []))
|
|
children = list(self.cleaned_data.get("children", []))
|
|
|
|
self.instance.parent.set(parents)
|
|
|
|
current_children = list(self.instance.child.all())
|
|
for child in current_children:
|
|
if child not in children:
|
|
self.instance.child.remove(child)
|
|
for child in children:
|
|
if child not in current_children:
|
|
self.instance.child.add(child)
|
|
|
|
return self.instance
|
|
|
|
|
|
class CaseCollectionForm(ModelForm):
|
|
class Meta:
|
|
model = CaseCollection
|
|
exclude = ["cases", "valid_cid_users", "valid_user_users", "author"]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.user = kwargs.pop("user")
|
|
if kwargs.get("instance"):
|
|
initial = kwargs.setdefault("initial", {})
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
super(CaseCollectionForm, self).__init__(*args, **kwargs)
|
|
|
|
# Render collection_type as radio buttons and ensure it appears near the top
|
|
if "collection_type" in self.fields:
|
|
# Replace the model field with an explicit ChoiceField that uses RadioSelect
|
|
original = self.fields["collection_type"]
|
|
choices = getattr(original, "choices", ())
|
|
initial = original.initial if hasattr(original, "initial") else None
|
|
required = original.required
|
|
label = original.label
|
|
widget = RadioSelect(attrs={"class": "btn-check"})
|
|
# assign custom template for rendering as button group
|
|
#widget.template_name = "widgets/radioselect_buttons.html"
|
|
self.fields["collection_type"] = forms.ChoiceField(
|
|
choices=choices,
|
|
widget=widget,
|
|
initial=initial,
|
|
required=required,
|
|
label=label,
|
|
)
|
|
|
|
if "prerequisites" in self.fields:
|
|
self.fields["prerequisites"].widget = autocomplete.ModelSelect2Multiple(
|
|
url="atlas:casecollection-prerequisite-autocomplete",
|
|
attrs={
|
|
"data-placeholder": "Search authored collections...",
|
|
"data-minimum-input-length": 0,
|
|
},
|
|
)
|
|
authored_collections = CaseCollection.objects.filter(author=self.user).distinct().order_by("name")
|
|
if getattr(self.instance, "pk", None):
|
|
authored_collections = authored_collections.exclude(pk=self.instance.pk)
|
|
self.fields["prerequisites"].queryset = authored_collections
|
|
|
|
# Identify show_ fields
|
|
show_fields = [f for f in self.fields if f.startswith("show_")]
|
|
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
|
|
show_post_fields = [f for f in show_fields if f.endswith("_post")]
|
|
|
|
self.helper = FormHelper()
|
|
self.helper.form_id = "id-casecollection-form"
|
|
self.helper.form_class = "casecollection-form"
|
|
self.helper.form_method = "post"
|
|
self.helper.form_action = ""
|
|
self.helper.form_tag = True
|
|
# Group cid_user_groups and user_user_groups together if they exist in the form
|
|
user_fields = []
|
|
for f in ["cid_user_groups", "user_user_groups"]:
|
|
if f in self.fields:
|
|
user_fields.append(f)
|
|
|
|
# Explicitly define show fields for pre and post
|
|
show_pre_fields = [
|
|
f
|
|
for f in [
|
|
"show_title_pre",
|
|
"show_history_pre",
|
|
"show_description_pre",
|
|
"show_discussion_pre",
|
|
"show_report_pre",
|
|
]
|
|
if f in self.fields
|
|
]
|
|
show_post_fields = [
|
|
f
|
|
for f in [
|
|
"show_title_post",
|
|
"show_history_post",
|
|
"show_description_post",
|
|
"show_discussion_post",
|
|
"show_report_post",
|
|
"show_case_link_post",
|
|
"show_findings_post",
|
|
"show_displaysets_post",
|
|
"show_differentials_post",
|
|
]
|
|
if f in self.fields
|
|
]
|
|
|
|
self.helper.layout = Layout(
|
|
Fieldset(
|
|
"Collection Details",
|
|
"name",
|
|
Field("collection_type", css_class="mb-2"),
|
|
Fieldset(
|
|
"Dates",
|
|
"restrict_to_dates",
|
|
Div(
|
|
Field(
|
|
"start_date",
|
|
wrapper_class="col-md-6",
|
|
css_class="form-group",
|
|
),
|
|
Field(
|
|
"end_date", wrapper_class="col-md-6", css_class="form-group"
|
|
),
|
|
css_class="form-row d-flex",
|
|
),
|
|
),
|
|
"active",
|
|
"publish_results",
|
|
"archive",
|
|
"open_access",
|
|
"candidates_only",
|
|
"authors_only",
|
|
"results_supervisor_visible",
|
|
"exam_open_access",
|
|
"markers",
|
|
"exam_mode",
|
|
"self_review",
|
|
"feedback_once_collection_complete",
|
|
"case_query_messaging_enabled",
|
|
"show_results_sharing_information",
|
|
"viewer_mode",
|
|
"question_time_limit",
|
|
"answer_entry_grace_period",
|
|
"prerequisites",
|
|
"pre_survey",
|
|
"pre_survey_mandatory",
|
|
"post_survey",
|
|
"post_survey_mandatory",
|
|
Fieldset(
|
|
"Custom Start Screen",
|
|
"start_screen_content",
|
|
"start_screen_resources",
|
|
),
|
|
Fieldset(
|
|
"Custom End Overview",
|
|
"end_overview_content",
|
|
"end_overview_resources",
|
|
),
|
|
),
|
|
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
|
Div(
|
|
Div(
|
|
Fieldset("Show fields (pre-exam)", *show_pre_fields),
|
|
css_class="col-md-6",
|
|
style="float:left;",
|
|
),
|
|
Div(
|
|
Fieldset("Show fields (post-exam)", *show_post_fields),
|
|
css_class="col-md-6",
|
|
style="float:left;",
|
|
),
|
|
css_class="row",
|
|
style="display: flex; flex-wrap: wrap;",
|
|
),
|
|
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
|
)
|
|
|
|
|
|
class NormalCaseForm(ModelForm):
|
|
# Keep non-model input fields for age entry so users can provide a
|
|
# value + unit (e.g. 6 months) which will be converted to canonical
|
|
# days on save. The model now stores only `age_days`.
|
|
age_value = forms.IntegerField(required=False, min_value=0)
|
|
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
|
|
|
|
class Meta:
|
|
model = NormalCase
|
|
fields = ("examination", "modality", "notes")
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# If editing an existing NormalCase, prepopulate age_value/age_unit
|
|
try:
|
|
instance = kwargs.get('instance')
|
|
except Exception:
|
|
instance = None
|
|
if instance is None:
|
|
# also support self.instance when ModelForm sets it
|
|
instance = getattr(self, 'instance', None)
|
|
if instance is not None and getattr(instance, 'age_days', None) is not None:
|
|
try:
|
|
val, unit = NormalCase.days_to_value_unit(instance.age_days)
|
|
if val is not None and unit is not None:
|
|
self.fields['age_value'].initial = val
|
|
self.fields['age_unit'].initial = unit
|
|
except Exception:
|
|
pass
|
|
self.helper = FormHelper()
|
|
# Do not render a <form> tag from crispy here because the template
|
|
# already provides the surrounding form element (we inject this
|
|
# fragment into a modal that contains the form). Rendering a second
|
|
# <form> would nest forms and cause fields to be outside the
|
|
# submitted form (HTMX would only send the outer form's fields).
|
|
self.helper.form_method = "post"
|
|
self.helper.form_tag = False
|
|
self.helper.layout = Layout(
|
|
Div(
|
|
Field("age_value", wrapper_class="col-md-3"),
|
|
Field("age_unit", wrapper_class="col-md-3"),
|
|
Field("examination", wrapper_class="col-md-3"),
|
|
Field("modality", wrapper_class="col-md-3"),
|
|
css_class="row",
|
|
),
|
|
Field("notes"),
|
|
# Submit button rendered in modal footer; avoid duplicate button here
|
|
)
|
|
|
|
def save(self, commit=True):
|
|
# Respect commit flag. Compute canonical `age_days` from the
|
|
# provided age_value/age_unit and set it on the instance.
|
|
instance = super().save(commit=False)
|
|
age_value = self.cleaned_data.get("age_value")
|
|
age_unit = self.cleaned_data.get("age_unit")
|
|
if age_value is not None and age_unit:
|
|
instance.age_days = NormalCase.to_days(age_value, age_unit)
|
|
else:
|
|
# If the user left fields blank, clear any previous value.
|
|
instance.age_days = None
|
|
|
|
if commit:
|
|
instance.save()
|
|
self.save_m2m()
|
|
|
|
return instance
|
|
|
|
|
|
class FindingForm(ModelForm):
|
|
class Meta:
|
|
model = Finding
|
|
exclude = []
|
|
# Use the canonical FK in forms (if users want to mark this Finding
|
|
# as an alias of another). The old `synonym` M2M has been removed.
|
|
widgets = {
|
|
"canonical": autocomplete.ModelSelect2(
|
|
url="atlas:finding-autocomplete"
|
|
),
|
|
}
|
|
|
|
|
|
class StructureForm(ModelForm):
|
|
class Meta:
|
|
model = Structure
|
|
exclude = []
|
|
widgets = {
|
|
"canonical": autocomplete.ModelSelect2(
|
|
url="atlas:structure-autocomplete"
|
|
),
|
|
}
|
|
|
|
|
|
class SubspecialtyForm(ModelForm):
|
|
class Meta:
|
|
model = Subspecialty
|
|
exclude = []
|
|
|
|
|
|
class PresentationForm(ModelForm):
|
|
class Meta:
|
|
model = Presentation
|
|
exclude = []
|
|
|
|
|
|
class PathologicalProcessForm(ModelForm):
|
|
class Meta:
|
|
model = PathologicalProcess
|
|
exclude = []
|
|
|
|
|
|
class ProcedureForm(ModelForm):
|
|
class Meta:
|
|
model = Procedure
|
|
exclude = []
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
try:
|
|
self.helper = FormHelper()
|
|
self.helper.form_method = "post"
|
|
# Prevent crispy from rendering its own <form> tag so our
|
|
# template-level form wrapper contains the submit buttons.
|
|
self.helper.form_tag = False
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class SeriesFindingForm(ModelForm):
|
|
class Meta:
|
|
model = SeriesFinding
|
|
exclude = [
|
|
"series",
|
|
"annotation_json",
|
|
"viewport_json",
|
|
"current_image_id_index",
|
|
"annotation_json_3d",
|
|
"viewer_state_3d",
|
|
]
|
|
|
|
widgets = {
|
|
"findings": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:finding-autocomplete"
|
|
),
|
|
"structures": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:structure-autocomplete"
|
|
),
|
|
"conditions": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:condition-autocomplete"
|
|
),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
if kwargs.get("series_id"):
|
|
# 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["series"] = kwargs.pop("series_id")
|
|
# elif 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["structures"] = [t.pk for t in kwargs["structures"].case.all()]
|
|
# #initial["structures"] = [t.pk for t in kwargs["structures"].case.all()]
|
|
|
|
super(SeriesFindingForm, self).__init__(*args, **kwargs)
|
|
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
|
|
|
|
class SeriesForm(ModelForm):
|
|
class Media:
|
|
# Django also includes a few javascript files necessary
|
|
# for the operation of this form element. You need to
|
|
# include <script src="/admin/jsi18n"></script>
|
|
# in the template.
|
|
css = {
|
|
"all": ["css/widgets.css"],
|
|
}
|
|
# Adding this javascript is crucial
|
|
js = ["jsi18n.js", "tesseract.min.js", "js/upload_form_helpers.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["case"] = [t.pk for t in kwargs["instance"].case.all()]
|
|
|
|
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)
|
|
|
|
if self.user.groups.filter(name="atlas_editor").exists():
|
|
case_queryset = Case.objects.all()
|
|
else:
|
|
case_queryset = Case.objects.filter(
|
|
author__id=self.user.id
|
|
) # | Case.objects.filter(open_access=True)
|
|
|
|
self.fields["case"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=case_queryset,
|
|
widget=FilteredSelectMultiple(verbose_name="Case", is_stacked=False),
|
|
)
|
|
|
|
def save(self, commit=True):
|
|
# Get the unsaved Long instance
|
|
instance = ModelForm.save(self, False)
|
|
|
|
# Prepare a 'save_m2m' method for the form,
|
|
old_save_m2m = self.save_m2m
|
|
|
|
def save_m2m():
|
|
old_save_m2m()
|
|
instance.case.clear()
|
|
for case in self.cleaned_data["case"]:
|
|
# We need the id here
|
|
instance.case.add(case.pk)
|
|
|
|
self.save_m2m = save_m2m
|
|
|
|
# Do we need to save all changes now?
|
|
# if commit:
|
|
instance.save()
|
|
self.save_m2m()
|
|
|
|
# instance.anonymise_images()
|
|
|
|
return instance
|
|
|
|
class Meta:
|
|
model = Series
|
|
exclude = ["author", "series_instance_uid"]
|
|
|
|
widgets = {
|
|
"examination": autocomplete.ModelSelect2(
|
|
url="generic:examination-autocomplete"
|
|
),
|
|
"description": Textarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
|
}
|
|
|
|
# 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
|
|
|
|
|
|
class CaseForm(ModelForm):
|
|
class Media:
|
|
# Django also includes a few javascript files necessary
|
|
# for the operation of this form element. You need to
|
|
# include <script src="/admin/jsi18n"></script>
|
|
# in the template.
|
|
# history = forms.CharField(
|
|
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
|
|
# required=False,
|
|
# )
|
|
|
|
css = {
|
|
"all": ["css/widgets.css"],
|
|
}
|
|
# Adding this javascript is crucial
|
|
js = ["jsi18n.js", "tesseract.min.js", "js/upload_form_helpers.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
|
|
|
|
# This is populated if we create the case from an exam/collection
|
|
self.collection = None
|
|
initial = kwargs.setdefault("initial", {})
|
|
if initial is not None and "exams" in initial:
|
|
collections = initial.pop("exams")
|
|
logger.debug(collections)
|
|
|
|
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
|
|
|
|
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(CaseForm, self).__init__(*args, **kwargs)
|
|
# self.fields["series"].queryset =
|
|
|
|
if self.user.groups.filter(name="atlas_editor").exists():
|
|
case_queryset = Case.objects.all().order_by("pk").prefetch_related()
|
|
else:
|
|
case_queryset = get_cases_available_to_user(self.user)
|
|
self.fields["previous_case"].queryset = case_queryset
|
|
|
|
self.helper = FormHelper()
|
|
self.helper.form_id = "id-case-form"
|
|
self.helper.form_class = "case-form"
|
|
self.helper.form_method = "post"
|
|
self.helper.form_action = "submit"
|
|
self.helper.form_tag = False
|
|
|
|
self.helper.layout = Layout(
|
|
Fieldset(
|
|
"Case details",
|
|
"title",
|
|
"subspecialty",
|
|
"description",
|
|
"history",
|
|
"presentation",
|
|
"discussion",
|
|
"report",
|
|
"procedures",
|
|
"condition",
|
|
"pathological_process",
|
|
"open_access",
|
|
"previous_case",
|
|
"diagnostic_certainty",
|
|
#cimar_uuid",
|
|
"study_date",
|
|
),
|
|
)
|
|
|
|
def get_queryset(self, request):
|
|
return (
|
|
super()
|
|
.get_queryset(request)
|
|
.prefetch_related(
|
|
"condition", "subspecialty", "pathological_process", "series"
|
|
)
|
|
)
|
|
|
|
class Meta:
|
|
model = Case
|
|
# fields = ['due_back']
|
|
# fields = '__all__'
|
|
fields = [
|
|
# "examination",
|
|
# "site",
|
|
"title",
|
|
"subspecialty",
|
|
"description",
|
|
"history",
|
|
"presentation",
|
|
"discussion",
|
|
"procedures",
|
|
"condition",
|
|
"pathological_process",
|
|
"report",
|
|
"open_access",
|
|
"previous_case",
|
|
"diagnostic_certainty",
|
|
#cimar_uuid",
|
|
"study_date",
|
|
]
|
|
# 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}),
|
|
"title": ClearableInput(attrs={"rows": 1}),
|
|
"description": ClearableTextarea(attrs={"cols": 80, "rows": 5}),
|
|
"history": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
|
"discussion": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
|
"report": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
|
|
"condition": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:condition-autocomplete"
|
|
),
|
|
"presentation": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:presentation-autocomplete"
|
|
),
|
|
"subspecialty": CheckboxSelectMultiple(),
|
|
"pathological_process": CheckboxSelectMultiple(),
|
|
"previous_case": CaseSelect(),
|
|
"procedures": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:procedure-autocomplete"
|
|
),
|
|
}
|
|
|
|
def clean_cimar_uuid(self):
|
|
cimar_uuid = self.cleaned_data["cimar_uuid"]
|
|
if cimar_uuid:
|
|
with CimarAPI(username=CIMAR_USERNAME, password=CIMAR_PASSWORD) as api:
|
|
try:
|
|
study = api.get_study_by_uuid(cimar_uuid)
|
|
|
|
except NotFoundError:
|
|
raise ValidationError("Study not found in CIMAR")
|
|
|
|
return cimar_uuid
|
|
|
|
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()
|
|
|
|
logger.debug(f"{self.collection=}")
|
|
|
|
if self.collection is not None:
|
|
logger.debug(f"{self.collection=}")
|
|
case_no = self.collection.cases.count() + 1
|
|
logger.debug(f"{case_no=}")
|
|
logger.debug(f"{instance=}")
|
|
|
|
# Create through model
|
|
# cd = CaseDetail(case=instance, collection=exam, sort_order=case_no)
|
|
self.collection.cases.add(
|
|
instance, through_defaults={"sort_order": case_no}
|
|
)
|
|
|
|
return instance
|
|
|
|
|
|
CaseDifferentialFormSet = inlineformset_factory(
|
|
Case,
|
|
Differential,
|
|
exclude=[],
|
|
can_delete=True,
|
|
extra=1,
|
|
max_num=10,
|
|
widgets={
|
|
"condition": autocomplete.ModelSelect2(url="atlas:condition-autocomplete"),
|
|
"text": Textarea(attrs={"maxlength": 1000, "rows": 3, "cols": 80}),
|
|
},
|
|
)
|
|
|
|
|
|
class ResourceForm(ModelForm):
|
|
class Meta:
|
|
model = Resource
|
|
exclude = ["author", "created_date"]
|
|
|
|
widgets = {
|
|
"description": Textarea(attrs={"cols": 80, "rows": 5}),
|
|
"url": TextInput(attrs={"cols": 80, "rows": 1}),
|
|
}
|
|
|
|
|
|
class CaseResourceForm(ModelForm):
|
|
def __init__(self, *args, user, **kwargs):
|
|
super(CaseResourceForm, self).__init__(*args, **kwargs)
|
|
|
|
if not user.groups.filter(name="atlas_editor").exists():
|
|
queryset = Resource.objects.filter(author__id=user.id)
|
|
else:
|
|
queryset = Resource.objects.all()
|
|
|
|
self.fields["resource"] = ModelChoiceField(
|
|
required=False,
|
|
queryset=queryset,
|
|
# widget=Select(verbose_name="Series", is_stacked=False),
|
|
)
|
|
|
|
self.fields["pre_review"] = BooleanField(required=False)
|
|
|
|
|
|
# class CaseSeriesForm(ModelForm):
|
|
#
|
|
# def __init__(self, *args, user, **kwargs):
|
|
# super(CaseSeriesForm, self).__init__(*args, **kwargs)
|
|
#
|
|
# if not user.groups.filter(name="atlas_editor").exists():
|
|
# queryset = Series.objects.filter(author__id=user.id)
|
|
# else:
|
|
# queryset = Series.objects.all()
|
|
#
|
|
# # Optimize the queryset for the ModelChoiceField
|
|
# queryset = queryset.select_related(
|
|
# "modality"
|
|
# ).prefetch_related(
|
|
# "examination", "plane", "contrast", "images"
|
|
# )
|
|
#
|
|
# self.fields["series"] = ModelChoiceField(
|
|
# required=False,
|
|
# queryset=queryset,
|
|
# # widget=Select(verbose_name="Series", is_stacked=False),
|
|
# )
|
|
|
|
|
|
class CaseCollectionCaseForm(ModelForm):
|
|
def __init__(self, *args, user, collection=None, **kwargs):
|
|
super(CaseCollectionCaseForm, self).__init__(*args, **kwargs)
|
|
|
|
queryset = get_cases_available_to_user(user, collection=collection)
|
|
|
|
self.fields["case"] = ModelChoiceField(
|
|
required=False,
|
|
queryset=queryset,
|
|
# widget=Select(verbose_name="Series", is_stacked=False),
|
|
)
|
|
|
|
|
|
# SeriesFormSet = inlineformset_factory(
|
|
# Case,
|
|
# Series.case.through,
|
|
# form=CaseSeriesForm,
|
|
# exclude=[],
|
|
# can_delete=True,
|
|
# extra=0,
|
|
# max_num=10,
|
|
# )
|
|
|
|
CaseCollectionCaseFormSet = inlineformset_factory(
|
|
CaseCollection,
|
|
Case.casecollection_set.through,
|
|
form=CaseCollectionCaseForm,
|
|
exclude=[
|
|
"question_schema",
|
|
"question_answers",
|
|
"series_visibility_config",
|
|
"learner_comment",
|
|
"learner_review_comment",
|
|
],
|
|
can_delete=True,
|
|
extra=0,
|
|
max_num=50,
|
|
)
|
|
|
|
CaseResourceFormSet = inlineformset_factory(
|
|
Case,
|
|
Resource.case_set.through,
|
|
form=CaseResourceForm,
|
|
exclude=[],
|
|
can_delete=True,
|
|
extra=1,
|
|
max_num=10,
|
|
)
|
|
|
|
SeriesImageFormSet = inlineformset_factory(
|
|
Series,
|
|
SeriesImage,
|
|
exclude=[
|
|
"dicom_tags_ohif",
|
|
"replaced",
|
|
"image_md5_hash",
|
|
"image_blake3_hash",
|
|
"is_dicom",
|
|
],
|
|
can_delete=True,
|
|
extra=0,
|
|
max_num=2000,
|
|
)
|
|
|
|
|
|
class JsonAnswerForm(Form):
|
|
json_answer = JSONSchemaField(
|
|
schema={},
|
|
# options = 'schema/options.json'
|
|
options="schema/options.json",
|
|
)
|
|
|
|
class Meta:
|
|
fields = ["json_answer"]
|
|
|
|
def __init__(self, *args, question_schema=None, **kwargs):
|
|
super(JsonAnswerForm, self).__init__(*args, **kwargs)
|
|
if question_schema is not None:
|
|
self.fields["json_answer"] = JSONSchemaField(
|
|
schema=question_schema, options="schema/options.json"
|
|
)
|
|
self.fields["json_answer"].label = False
|
|
|
|
|
|
class BaseReportAnswerForm(ModelForm):
|
|
class Meta:
|
|
fields = ["answer"]
|
|
|
|
widgets = {
|
|
# "normal": RadioSelect(
|
|
# choices=[(True, 'Yes'),
|
|
# (False, 'No')])
|
|
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
|
|
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
|
|
"answer": Textarea(
|
|
attrs={"cols": 100, "rows": 5, "placeholder": "Report text"}
|
|
),
|
|
}
|
|
|
|
# help_texts = {
|
|
# "answer": "Write your answer in here."
|
|
# }
|
|
|
|
def __init__(self, *args, casedetail, **kwargs):
|
|
super(BaseReportAnswerForm, self).__init__(*args, **kwargs)
|
|
self.fields["answer"].required = False
|
|
|
|
|
|
class BaseQuestionAnswerForm(ModelForm):
|
|
json_answer = JSONSchemaField(schema={}, options="schema/options.json")
|
|
|
|
class Meta:
|
|
fields = ["json_answer"]
|
|
labels = {"json_answer": ""}
|
|
|
|
# help_texts = {
|
|
# "answer": "Write your answer in here."
|
|
# }
|
|
|
|
def __init__(self, *args, casedetail, **kwargs):
|
|
super(BaseQuestionAnswerForm, self).__init__(*args, **kwargs)
|
|
# self.fields["json_answer"].schema = casedetail.question_schema
|
|
if casedetail.question_schema is not None:
|
|
self.fields["json_answer"] = JSONSchemaField(
|
|
schema=casedetail.question_schema, options="schema/options.json"
|
|
)
|
|
self.fields["json_answer"].label = ""
|
|
|
|
|
|
class CidReportAnswerForm(BaseReportAnswerForm):
|
|
class Meta(BaseReportAnswerForm.Meta):
|
|
model = CidReportAnswer
|
|
|
|
|
|
class UserReportAnswerForm(BaseReportAnswerForm):
|
|
class Meta(BaseReportAnswerForm.Meta):
|
|
model = UserReportAnswer
|
|
|
|
|
|
class CidQuestionAnswerForm(BaseQuestionAnswerForm):
|
|
class Meta(BaseQuestionAnswerForm.Meta):
|
|
model = CidReportAnswer
|
|
|
|
|
|
class UserQuestionAnswerForm(BaseQuestionAnswerForm):
|
|
class Meta(BaseQuestionAnswerForm.Meta):
|
|
model = UserReportAnswer
|
|
|
|
|
|
class CidReportAnswerMarkForm(ModelForm):
|
|
class Meta:
|
|
model = CidReportAnswer
|
|
fields = ["score", "feedback"]
|
|
|
|
widgets = {
|
|
# "normal": RadioSelect(
|
|
# choices=[(True, 'Yes'),
|
|
# (False, 'No')])
|
|
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
|
|
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
|
|
"feedback": Textarea(attrs={"cols": 80, "rows": 4}),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(CidReportAnswerMarkForm, self).__init__(*args, **kwargs)
|
|
self.fields["score"].required = False
|
|
|
|
|
|
# class AddCollectionToCaseForm(Form):
|
|
#
|
|
# def __init__(self, *args, user, **kwargs):
|
|
# super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
|
|
#
|
|
# if not user.groups.filter(name="atlas_editor").exists():
|
|
# queryset = CaseCollection.objects.filter(author__id=user.id)
|
|
# else:
|
|
# queryset = CaseCollection.objects.all()
|
|
#
|
|
# self.valid_collections = queryset
|
|
# self.case = get_object_or_404(Case, pk=pk)
|
|
#
|
|
# self.fields["casecollection"] = ModelMultipleChoiceField(
|
|
# required=False,
|
|
# queryset=queryset,
|
|
# # widget=Select(verbose_name="Series", is_stacked=False),
|
|
# )
|
|
#
|
|
# def save(self, *args, **kwargs):
|
|
# print("SAVE", args, kwargs)
|
|
# instance = super(AddCollectionToCaseForm, self).save(*args, **kwargs)
|
|
#
|
|
# # Here we save the modified project selection back into the database
|
|
# instance.casecollection_set.set(self.cleaned_data['casecollection'])
|
|
#
|
|
# print(f"{instance=}")
|
|
#
|
|
# return instance
|
|
#
|
|
# def clean_casecollection(self):
|
|
# collection_set = self.cleaned_data["casecollection"]
|
|
#
|
|
# print(f"{collection_set=}")
|
|
#
|
|
# for col in collection_set:
|
|
# if col not in self.valid_collections:
|
|
# raise ValidationError("Invalid collection")
|
|
#
|
|
# return collection_set
|
|
|
|
|
|
class AddCollectionToCaseForm(Form):
|
|
def __init__(self, *args, user, **kwargs):
|
|
super(AddCollectionToCaseForm, self).__init__(*args, **kwargs)
|
|
|
|
if not user.groups.filter(name="atlas_editor").exists():
|
|
queryset = CaseCollection.objects.filter(author__id=user.id)
|
|
else:
|
|
queryset = CaseCollection.objects.all()
|
|
|
|
self.valid_collections = queryset
|
|
|
|
self.fields["casecollection"] = ModelMultipleChoiceField(
|
|
required=False,
|
|
queryset=queryset,
|
|
# widget=Select(verbose_name="Series", is_stacked=False),
|
|
)
|
|
|
|
def save(self, *args, **kwargs):
|
|
instance = super(AddCollectionToCaseForm, self).save(*args, **kwargs)
|
|
|
|
# Here we save the modified project selection back into the database
|
|
instance.casecollection_set.set(self.cleaned_data["casecollection"])
|
|
|
|
return instance
|
|
|
|
def clean_casecollection(self):
|
|
collection_set = self.cleaned_data["casecollection"]
|
|
|
|
for col in collection_set:
|
|
if col not in self.valid_collections:
|
|
raise ValidationError("Invalid collection")
|
|
|
|
return collection_set
|
|
|
|
|
|
class SelfReviewForm(ModelForm):
|
|
SCORE_CHOICES = [
|
|
(1, "1 - Poor"),
|
|
(2, "2 - Limited"),
|
|
(3, "3 - Adequate"),
|
|
(4, "4 - Good"),
|
|
(5, "5 - Excellent"),
|
|
]
|
|
|
|
class Meta:
|
|
model = SelfReview
|
|
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
|
|
widgets = {"user_exam": HiddenInput(), "case": HiddenInput()}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
if "user_exam_id" in kwargs:
|
|
user_exam_id = kwargs.pop("user_exam_id")
|
|
if "case_id" in kwargs:
|
|
case_id = kwargs.pop("case_id")
|
|
|
|
ModelForm.__init__(self, *args, **kwargs)
|
|
super(SelfReviewForm, self).__init__(*args, **kwargs)
|
|
|
|
# Render scoring fields as explicit 1-5 selector choices for cleaner UX.
|
|
self.fields["findings"] = forms.TypedChoiceField(
|
|
choices=self.SCORE_CHOICES,
|
|
coerce=int,
|
|
empty_value=None,
|
|
required=False,
|
|
widget=RadioSelect,
|
|
label="Findings",
|
|
help_text="How well did you identify key findings?",
|
|
)
|
|
self.fields["interpretation"] = forms.TypedChoiceField(
|
|
choices=self.SCORE_CHOICES,
|
|
coerce=int,
|
|
empty_value=None,
|
|
required=False,
|
|
widget=RadioSelect,
|
|
label="Interpretation",
|
|
help_text="How strong was your overall interpretation?",
|
|
)
|
|
|
|
self.fields["comments"].widget.attrs.update(
|
|
{
|
|
"rows": 5,
|
|
"placeholder": "What went well? What would you do differently next time?",
|
|
}
|
|
)
|
|
|
|
self.helper = FormHelper()
|
|
self.helper.form_method = "post"
|
|
self.helper.form_tag = False
|
|
self.helper.layout = Layout(
|
|
Field("user_exam"),
|
|
Field("case"),
|
|
Field("comments"),
|
|
Field("findings"),
|
|
Field("interpretation"),
|
|
)
|
|
|
|
|
|
class CaseReviewMessageForm(ModelForm):
|
|
class Meta:
|
|
model = CaseReviewMessage
|
|
fields = ["message", "requires_acknowledgement"]
|
|
|
|
def __init__(self, *args, allow_ack=False, message_placeholder=None, **kwargs):
|
|
super(CaseReviewMessageForm, self).__init__(*args, **kwargs)
|
|
|
|
self.fields["message"].widget.attrs.update(
|
|
{
|
|
"rows": 3,
|
|
"placeholder": message_placeholder or "Write a message...",
|
|
"class": "form-control",
|
|
}
|
|
)
|
|
|
|
self.fields["requires_acknowledgement"].widget.attrs.update(
|
|
{
|
|
"class": "form-check-input",
|
|
}
|
|
)
|
|
|
|
if not allow_ack:
|
|
self.fields["requires_acknowledgement"].widget = HiddenInput()
|
|
self.fields["requires_acknowledgement"].required = False
|
|
|
|
self.helper = FormHelper()
|
|
self.helper.form_method = "post"
|
|
self.helper.form_tag = False
|
|
|
|
|
|
class UncategorisedDicomForm(ModelForm):
|
|
class Meta:
|
|
model = UncategorisedDicom
|
|
fields = ["image"] # , "user"]
|
|
|
|
|
|
# UncategorisedDicomFormset = formset_factory(UncategorisedDicomForm)
|
|
|
|
# class ConditionAutocompleteFormModel(forms.ModelForm):
|
|
# class Meta:
|
|
# model = Condition
|
|
# fields = ["name"]
|
|
# widgets = {
|
|
# 'members': widgets.Autocomplete(
|
|
# name='members',
|
|
# options=dict(multiselect=True, model=Condition)
|
|
# )
|
|
# }
|
|
|
|
|
|
class ConditionAutocomplete(ModelAutocomplete):
|
|
model = Condition
|
|
search_attrs = ["name"]
|
|
|
|
@classmethod
|
|
def get_items_from_keys(cls, keys, context_obj=None):
|
|
"""Return items for given keys, ignoring empty strings.
|
|
|
|
The autocomplete frontend sometimes sends empty string keys which
|
|
causes the ORM to raise ValueError when filtering numeric PK fields.
|
|
Filter out falsy/blank keys before querying. If no valid keys remain
|
|
return an empty queryset.
|
|
"""
|
|
# normalize and remove empty/blank values
|
|
clean_keys = [k for k in keys if k is not None and str(k).strip() != ""]
|
|
if not clean_keys:
|
|
return cls.model.objects.none()
|
|
|
|
# try to convert numeric keys to ints where possible
|
|
parsed_keys = []
|
|
for k in clean_keys:
|
|
try:
|
|
parsed_keys.append(int(k))
|
|
except Exception:
|
|
parsed_keys.append(k)
|
|
|
|
qs = cls.model.objects.filter(pk__in=parsed_keys)
|
|
|
|
# Return a list of dicts matching the autocomplete core expectations
|
|
# (items subscriptable by keys like 'key' / 'label').
|
|
return [
|
|
{"key": obj.pk, "label": getattr(obj, "name", str(obj)), "value": str(obj)}
|
|
for obj in qs
|
|
]
|
|
|
|
|
|
class ConditionAutocompleteForm(Form):
|
|
condition = ModelChoiceField(
|
|
queryset=Condition.objects.all(),
|
|
widget=AutocompleteWidget(
|
|
ac_class=ConditionAutocomplete # , options=dict(model=Condition)
|
|
),
|
|
)
|
|
|
|
|
|
autocomplete_register(ConditionAutocomplete)
|
|
|
|
|
|
class CaseCollectionAuthorForm(ExamAuthorFormMixin):
|
|
class Meta(ExamAuthorFormMixin.Meta):
|
|
model = CaseCollection
|
|
|
|
|
|
class CaseAuthorForm(ExamAuthorFormMixin):
|
|
class Meta(ExamAuthorFormMixin.Meta):
|
|
model = Case
|
|
|
|
|
|
class SeriesAuthorForm(ExamAuthorFormMixin):
|
|
class Meta(ExamAuthorFormMixin.Meta):
|
|
model = Series
|
|
|
|
|
|
class ExamGroupsForm(ExamGroupsFormMixin):
|
|
class Meta(ExamGroupsFormMixin.Meta):
|
|
model = CaseCollection
|
|
|
|
|
|
class AnswerJSONForm(Form):
|
|
json = JSONSchemaField(schema="schema/schema.json", options="schema/options.json")
|
|
|
|
|
|
class SvelteJSONEditorWidgetOverride(SvelteJSONEditorWidget):
|
|
template_name = "atlas/svelte_jsoneditor_widget_override.html"
|
|
|
|
|
|
class CaseQuestionForm(ModelForm):
|
|
class Meta:
|
|
model = CaseDetail
|
|
fields = ["question_schema", "question_answers"] # , "user"]
|
|
|
|
widgets = {
|
|
"question_schema": SvelteJSONEditorWidgetOverride(),
|
|
"question_answers": SvelteJSONEditorWidgetOverride(),
|
|
}
|
|
|
|
class CaseDetailForm(ModelForm):
|
|
class Meta:
|
|
model = CaseDetail
|
|
fields = [
|
|
"redact_history",
|
|
"override_history",
|
|
"learner_comment",
|
|
"learner_review_comment",
|
|
"question_time_limit_override",
|
|
"answer_entry_grace_period_override",
|
|
]
|
|
|
|
def __init__(self, *args, case_history: str = None, **kwargs):
|
|
"""
|
|
case_history: optional explicit history text to show (falls back to instance.case.history)
|
|
"""
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Determine original history text (safe-escaped for HTML)
|
|
if case_history is None:
|
|
try:
|
|
case_history = self.instance.case.history if self.instance and getattr(self.instance, "case", None) else ""
|
|
except Exception:
|
|
case_history = ""
|
|
case_history_html = escape(case_history or "No history available.")
|
|
|
|
# Ensure override_history has a stable id we can reference from the inline script
|
|
override_id = self.fields["override_history"].widget.attrs.get("id", "id_override_history")
|
|
self.fields["override_history"].widget.attrs["id"] = override_id
|
|
# Optionally make it a textarea style appearance if not already
|
|
self.fields["override_history"].widget.attrs.setdefault("rows", 6)
|
|
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
|
|
|
|
if "question_time_limit_override" in self.fields:
|
|
self.fields["question_time_limit_override"].help_text = (
|
|
"Leave blank to use the collection default answer time limit."
|
|
)
|
|
|
|
if "answer_entry_grace_period_override" in self.fields:
|
|
self.fields["answer_entry_grace_period_override"].help_text = (
|
|
"Leave blank to use the collection default answer-only grace period."
|
|
)
|
|
|
|
# Build crispy helper/layout embedding the original history and buttons tied to override_history
|
|
self.helper = FormHelper()
|
|
self.helper.form_tag = False
|
|
|
|
# Inline HTML block with buttons and a script that copies/clears the override field.
|
|
# The script references the explicit override_id above.
|
|
history_block = f"""
|
|
<details class="mb-3">
|
|
<summary class="h6" style="cursor:pointer;"><i class="bi bi-info-circle"></i> Show original case history</summary>
|
|
<div class="mt-2 p-2 bg-dark text-light border rounded">
|
|
<label class="form-label fw-bold">Original history (read-only)</label>
|
|
<div id="original-history" style="white-space: pre-wrap;">{case_history_html}</div>
|
|
<div class="form-text text-secondary">Use the buttons below to copy this into the override field or clear the override.</div>
|
|
<div class="mt-2">
|
|
<button type="button" class="btn btn-sm btn-outline-light" id="use-original-history">Copy original into override</button>
|
|
<button type="button" class="btn btn-sm btn-outline-danger ms-2" id="clear-override-history">Clear override</button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
var copyBtn = document.getElementById('use-original-history');
|
|
var clearBtn = document.getElementById('clear-override-history');
|
|
var original = '{case_history_html.replace("'", "\\'").replace("\\n", "\\\\n")}';
|
|
var overrideField = document.getElementById('{override_id}');
|
|
function setOverride(val) {{
|
|
if(!overrideField) return; overrideField.value = val; overrideField.dispatchEvent(new Event('input',{{bubbles:true}})); }}
|
|
if(copyBtn) copyBtn.addEventListener('click', function(e){{ setOverride(original); }});
|
|
if(clearBtn) clearBtn.addEventListener('click', function(e){{ setOverride(''); }});
|
|
}});
|
|
</script>
|
|
"""
|
|
|
|
# Compose layout: history block then the override field and redact checkbox
|
|
self.helper.layout = Layout(
|
|
HTML(history_block),
|
|
Field("override_history"),
|
|
Field("redact_history"),
|
|
Fieldset(
|
|
"Learner Comments",
|
|
Field("learner_comment"),
|
|
Field("learner_review_comment"),
|
|
),
|
|
Fieldset(
|
|
"Case Timing Overrides",
|
|
Field("question_time_limit_override"),
|
|
Field("answer_entry_grace_period_override"),
|
|
),
|
|
)
|
|
|
|
|
|
|
|
class QuestionSchemaForm(ModelForm):
|
|
class Meta:
|
|
model = QuestionSchema
|
|
fields = ["name", "description", "schema"]
|
|
|
|
widgets = {
|
|
"schema": SvelteJSONEditorWidgetOverride(),
|
|
}
|
|
|
|
|
|
# class ModelPriorCaseForm(ModelForm):
|
|
# class Meta:
|
|
# model = CasePrior
|
|
# fields = ["case_detail", "prior_case", "relation_text", "prior_visibility"]
|
|
|
|
|
|
class PriorCaseForm(Form):
|
|
relation = CharField(max_length=255, required=True)
|
|
|
|
prior_visibility = CharField(max_length=2, required=True)
|
|
|
|
|
|
class CaseSeriesForm(forms.ModelForm):
|
|
def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["series"].queryset = queryset
|
|
self.fields["series"].label_from_instance = lambda obj: str(obj.pk)
|
|
|
|
class Meta:
|
|
model = SeriesDetail # through model for Case.series
|
|
|
|
fields = ["series", "sort_order", "feedback"]
|
|
|
|
|
|
class CaseUpdateSeriesForm(forms.ModelForm):
|
|
def __init__(self, *args, **kwargs):
|
|
self.user = kwargs.pop("user")
|
|
super().__init__(*args, **kwargs)
|
|
self.helper = FormHelper()
|
|
self.helper.form_id = "id-case-form"
|
|
self.helper.form_class = "case-form"
|
|
self.helper.form_method = "post"
|
|
self.helper.form_action = "submit"
|
|
self.helper.form_tag = False
|
|
self.helper.layout = Layout()
|
|
|
|
class Meta:
|
|
model = Case
|
|
fields = [] # No fields, just for compatibility with UpdateView
|
|
|
|
|
|
class BaseSeriesFormSet(BaseInlineFormSet):
|
|
def __init__(self, *args, queryset=None, **kwargs):
|
|
self.series_queryset = queryset
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def get_form_kwargs(self, index):
|
|
kwargs = super().get_form_kwargs(index)
|
|
kwargs["queryset"] = self.series_queryset
|
|
return kwargs
|
|
|
|
|
|
SeriesFormSet = inlineformset_factory(
|
|
Case,
|
|
SeriesDetail,
|
|
form=CaseSeriesForm,
|
|
formset=BaseSeriesFormSet,
|
|
extra=1,
|
|
can_delete=True,
|
|
)
|
|
|
|
|
|
class CaseDisplaySetForm(forms.ModelForm):
|
|
class Meta:
|
|
model = CaseDisplaySet
|
|
fields = [
|
|
"name",
|
|
"description",
|
|
"findings",
|
|
"structures",
|
|
"conditions",
|
|
]
|
|
widgets = {
|
|
"name": forms.TextInput(
|
|
attrs={"class": "form-control", "placeholder": "Display Set Name"}
|
|
),
|
|
"description": forms.Textarea(
|
|
attrs={"class": "form-control", "rows": 3, "placeholder": "Description"}
|
|
),
|
|
"findings": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:finding-autocomplete"
|
|
),
|
|
"structures": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:structure-autocomplete"
|
|
),
|
|
"conditions": autocomplete.ModelSelect2Multiple(
|
|
url="atlas:condition-autocomplete"
|
|
),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# Optionally, you can filter queryset for findings/structures/conditions here if needed
|
|
|
|
|
|
class CaseCollectionUpdateCaseForm(forms.ModelForm):
|
|
def __init__(self, *args, **kwargs):
|
|
self.user = kwargs.pop("user")
|
|
self.collection = kwargs.pop("collection", None)
|
|
super().__init__(*args, **kwargs)
|
|
self.helper = FormHelper()
|
|
self.helper.form_id = "id-case-form"
|
|
self.helper.form_class = "case-form"
|
|
self.helper.form_method = "post"
|
|
self.helper.form_action = "submit"
|
|
self.helper.form_tag = False
|
|
self.helper.layout = Layout()
|
|
|
|
class Meta:
|
|
model = CaseCollection
|
|
fields = [] # No fields, just for compatibility with UpdateView
|