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
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 # 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 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 # 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 # 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"""
Show original case history
{case_history_html}
Use the buttons below to copy this into the override field or clear the override.
""" # 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 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