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", 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