Coverage for crateweb/research/forms.py: 44%
200 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-08-27 10:34 -0500
« prev ^ index » next coverage.py v7.8.0, created at 2025-08-27 10:34 -0500
1"""
2crate_anon/crateweb/research/forms.py
4===============================================================================
6 Copyright (C) 2015, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CRATE.
11 CRATE is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation, either version 3 of the License, or
14 (at your option) any later version.
16 CRATE is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with CRATE. If not, see <https://www.gnu.org/licenses/>.
24===============================================================================
26**Django forms for the research site.**
28"""
30import datetime
31import logging
32from typing import Any, Dict, List, Optional, Type
34from cardinal_pythonlib.django.forms import (
35 MultipleIntAreaField,
36 MultipleWordAreaField,
37)
38from django import forms
39from django.forms import (
40 BooleanField,
41 CharField,
42 ChoiceField,
43 DateField,
44 FileField,
45 FloatField,
46 IntegerField,
47 ModelForm,
48)
50from crate_anon.crateweb.research.models import Highlight, Query
51from crate_anon.crateweb.research.research_db_info import (
52 SingleResearchDatabase,
53)
54from crate_anon.common.sql import (
55 SQL_OPS_MULTIPLE_VALUES,
56 SQL_OPS_VALUE_UNNECESSARY,
57 QB_DATATYPE_DATE,
58 QB_DATATYPE_FLOAT,
59 QB_DATATYPE_INTEGER,
60 QB_DATATYPE_UNKNOWN,
61 QB_STRING_TYPES,
62)
64log = logging.getLogger(__name__)
67class AddQueryForm(ModelForm):
68 """
69 Form to add or edit an SQL
70 :class:`crate_anon.crateweb.research.models.Query`.
71 """
73 class Meta:
74 model = Query
75 fields = ["sql"]
76 widgets = {
77 "sql": forms.Textarea(attrs={"rows": 20, "cols": 80}),
78 }
81class BlankQueryForm(ModelForm):
82 """
83 Unused? For :class:`crate_anon.crateweb.research.models.Query`.
84 """
86 class Meta:
87 model = Query
88 fields = [] # type: List[str]
91class AddHighlightForm(ModelForm):
92 """
93 Form to add/edit a :class:`crate_anon.crateweb.research.models.Highlight`.
94 """
96 class Meta:
97 model = Highlight
98 fields = ["colour", "text"]
101class BlankHighlightForm(ModelForm):
102 """
103 Unused? For :class:`crate_anon.crateweb.research.models.Highlight`.
104 """
106 class Meta:
107 model = Highlight
108 fields = [] # type: List[str]
111class DatabasePickerForm(forms.Form):
112 """
113 Form to choose a research database.
114 """
116 database = ChoiceField(label="Database", required=True)
118 def __init__(
119 self, *args, dbinfolist: List[SingleResearchDatabase], **kwargs
120 ) -> None:
121 """
122 Args:
123 dbinfolist:
124 list of all
125 :class:`crate_anon.crateweb.research.research_db_info.SingleResearchDatabase`.
126 """
127 super().__init__(*args, **kwargs)
128 f = self.fields["database"] # type: ChoiceField
129 f.choices = [(d.name, d.description) for d in dbinfolist]
132class PidLookupForm(forms.Form):
133 """
134 Form to look up patient IDs from RIDs, MRIDs, and/or TRIDs.
136 For the RDBM.
137 """
139 rids = MultipleWordAreaField(required=False)
140 mrids = MultipleWordAreaField(required=False)
141 trids = MultipleIntAreaField(required=False)
143 def __init__(
144 self, *args, dbinfo: SingleResearchDatabase, **kwargs
145 ) -> None:
146 """
147 Args:
148 dbinfo:
149 research database to look up descriptions from, as a
150 :class:`crate_anon.crateweb.research.research_db_info.SingleResearchDatabase`
151 """
152 super().__init__(*args, **kwargs)
153 rids = self.fields["rids"] # type: MultipleIntAreaField
154 mrids = self.fields["mrids"] # type: MultipleIntAreaField
155 trids = self.fields["trids"] # type: MultipleIntAreaField
156 rids.label = f"{dbinfo.rid_field}: {dbinfo.rid_description} (RID)"
157 mrids.label = f"{dbinfo.mrid_field}: {dbinfo.mrid_description} (MRID)"
158 trids.label = f"{dbinfo.trid_field}: {dbinfo.trid_description} (TRID)"
161class RidLookupForm(forms.Form):
162 """
163 Form to look up RIDs from PIDs/MPIDs.
165 For clinicians: "get the RID for my patient".
166 """
168 pids = MultipleWordAreaField(required=False)
169 mpids = MultipleWordAreaField(required=False)
171 def __init__(
172 self, *args, dbinfo: SingleResearchDatabase, **kwargs
173 ) -> None:
174 """
175 Args:
176 dbinfo:
177 research database to look up descriptions from, as a
178 :class:`crate_anon.crateweb.research.research_db_info.SingleResearchDatabase`
179 """
180 super().__init__(*args, **kwargs)
181 pids = self.fields["pids"] # type: MultipleIntAreaField
182 mpids = self.fields["mpids"] # type: MultipleIntAreaField
183 pids.label = f"{dbinfo.pid_description} (PID)"
184 mpids.label = f"{dbinfo.mpid_description} (MPID)"
187DEFAULT_MIN_TEXT_FIELD_LENGTH = 100
190class FieldPickerInfo:
191 """
192 Describes a database field for when the user is asked to choose one via a
193 web form.
194 """
196 def __init__(
197 self, value: str, description: str, type_: Type, permits_empty_id: bool
198 ) -> None:
199 self.value = value
200 self.description = description
201 self.type_ = type_
202 self.permits_empty_id = permits_empty_id
205class SQLHelperFindAnywhereForm(forms.Form):
206 """
207 Base class for finding something anywhere in a patient's record.
209 The user gets to pick
211 - a field name (consistent across tables) representing a patient research
212 ID (see :class:`FieldPickerInfo`);
213 - a RID value
214 - options to restrict which text fields are searched
215 - an option to use full-text indexing where available
216 - display options
218 The subclasses then choose what the user should search for.
219 """
221 fkname = ChoiceField(required=True)
222 patient_id = CharField(
223 label="ID value (to restrict to a single patient)", required=False
224 )
225 use_fulltext_index = BooleanField(
226 label="Use full-text indexing where available "
227 "(faster, but requires whole words)",
228 required=False,
229 )
230 min_length = IntegerField(
231 label=f"Minimum 'width' of textual field to include "
232 f"(e.g. {DEFAULT_MIN_TEXT_FIELD_LENGTH})",
233 min_value=1,
234 required=True,
235 )
236 include_content = BooleanField(
237 label="Include content from fields where found (slower)",
238 required=False,
239 )
240 include_datetime = BooleanField(
241 label="Include date/time where known", required=False
242 )
244 def __init__(
245 self,
246 *args,
247 fk_options: List[FieldPickerInfo],
248 fk_label: str = "Field name containing patient research ID",
249 **kwargs,
250 ) -> None:
251 super().__init__(*args, **kwargs)
252 self.fk_options = fk_options
253 # Set the choices available for fkname
254 f = self.fields["fkname"] # type: ChoiceField
255 f.choices = [(opt.value, opt.description) for opt in fk_options]
256 f.label = fk_label
258 def clean(self) -> Dict[str, Any]:
259 cleaned_data = super().clean()
260 fieldname = cleaned_data.get("fkname")
261 pidvalue = cleaned_data.get("patient_id")
262 if fieldname:
263 opt = next(o for o in self.fk_options if o.value == fieldname)
264 if pidvalue:
265 try:
266 _ = opt.type_(pidvalue)
267 except (TypeError, ValueError):
268 raise forms.ValidationError(
269 f"For field {opt.description!r}, the ID value must be "
270 f"of type {opt.type_}"
271 )
272 else:
273 self._check_permits_empty_id_for_blank_id(opt)
274 return cleaned_data
276 def _check_permits_empty_id_for_blank_id(
277 self, opt: FieldPickerInfo
278 ) -> None:
279 # Exists as a function so ClinicianAllTextFromPidForm can override it.
280 if not opt.permits_empty_id:
281 raise forms.ValidationError(
282 f"For this ID type ({opt.value}), "
283 f"you must specify an ID value"
284 )
287# class SQLHelperTextAnywhereForm(forms.Form):
288# """
289# Form for "find text anywhere in a patient's record".
290#
291# The user gets to pick
292#
293# - a field name (consistent across tables) representing a patient research
294# ID (see :class:`FieldPickerInfo`);
295# - a RID value
296# - details of the text to search for
297# - options to restrict which text fields are searched
298# - an option to use full-text indexing where available
299# - display options
300#
301# This research-oriented form is then subclassed for clinicians; see
302# :class:`ClinicianAllTextFromPidForm`.
303# """
304# fkname = ChoiceField(required=True)
305# patient_id = CharField(label="ID value (to restrict to a single patient)", # noqa: E501
306# required=False)
307# fragment = CharField(label="String fragment to find", required=True)
308# use_fulltext_index = BooleanField(
309# label="Use full-text indexing where available "
310# "(faster, but requires whole words)",
311# required=False)
312# min_length = IntegerField(
313# label="Minimum 'width' of textual field to include (e.g. {})".format(
314# DEFAULT_MIN_TEXT_FIELD_LENGTH
315# ),
316# min_value=1, required=True)
317# include_content = BooleanField(
318# label="Include content from fields where found (slower)",
319# required=False)
320# include_datetime = BooleanField(
321# label="Include date/time from where known",
322# required=False)
323#
324# def __init__(
325# self,
326# *args,
327# fk_options: List[FieldPickerInfo],
328# fk_label: str = "Field name containing patient research ID",
329# **kwargs) -> None:
330# super().__init__(*args, **kwargs)
331# self.fk_options = fk_options
332# # Set the choices available for fkname
333# f = self.fields['fkname'] # type: ChoiceField
334# f.choices = [(opt.value, opt.description) for opt in fk_options]
335# f.label = fk_label
336#
337# def clean(self) -> Dict[str, Any]:
338# cleaned_data = super().clean()
339# fieldname = cleaned_data.get("fkname")
340# pidvalue = cleaned_data.get("patient_id")
341# if fieldname:
342# opt = next(o for o in self.fk_options if o.value == fieldname)
343# if pidvalue:
344# try:
345# _ = opt.type_(pidvalue)
346# except (TypeError, ValueError):
347# raise forms.ValidationError(
348# "For field {!r}, the ID value must be of "
349# "type {}".format(opt.description, opt.type_))
350# else:
351# self._check_permits_empty_id_for_blank_id(opt)
352# return cleaned_data
353#
354# def _check_permits_empty_id_for_blank_id(self,
355# opt: FieldPickerInfo) -> None:
356# # Exists as a function so ClinicianAllTextFromPidForm can override it. # noqa: E501
357# if not opt.permits_empty_id:
358# raise forms.ValidationError(
359# "For this ID type ({}), you must specify an ID "
360# "value".format(opt.value))
363class SQLHelperTextAnywhereForm(SQLHelperFindAnywhereForm):
364 """
365 Form for "find text anywhere in a patient's record".
367 The user gets to pick
369 - a field name (consistent across tables) representing a patient research
370 ID (see :class:`FieldPickerInfo`);
371 - a RID value
372 - details of the text to search for
373 - options to restrict which text fields are searched
374 - an option to use full-text indexing where available
375 - display options
377 This research-oriented form is then subclassed for clinicians; see
378 :class:`ClinicianAllTextFromPidForm`.
379 """
381 fragment = CharField(label="String fragment to find", required=True)
383 def __init__(self, *args, **kwargs) -> None:
384 super().__init__(*args, **kwargs)
387class ClinicianAllTextFromPidForm(SQLHelperTextAnywhereForm):
388 """
389 A slightly restricted form of :class:`SQLHelperTextAnywhereForm` for
390 clinicians.
392 The clinician version always requires an ID (no "patient browsing"; that's
393 in the domain of research as it might yield patients that aren't being
394 cared for by this clinician).
395 """
397 patient_id = CharField(label="ID value", required=True)
399 def __init__(self, *args, **kwargs) -> None:
400 super().__init__(
401 *args, fk_label="Field name containing patient ID", **kwargs
402 )
403 inccontent = self.fields["include_content"] # type: BooleanField
404 incdate = self.fields["include_datetime"] # type: BooleanField
406 # Hide include_content/include_datetime (always true here)
407 # inccontent.widget = inccontent.hidden_widget # ... nope!
408 inccontent.widget = forms.HiddenInput() # yes, this works
409 incdate.widget = forms.HiddenInput()
411 def _check_permits_empty_id_for_blank_id(
412 self, opt: FieldPickerInfo
413 ) -> None:
414 return
417class SQLHelperDrugTypeForm(SQLHelperFindAnywhereForm):
418 """
419 Form for "find drug of a given type anywhere in a patient's record".
421 Same as 'SQLHelperTextAnywhereForm' except the user picks a drug type to
422 search for instead of a string fragment.
423 """
425 # Left these all in because I didn't know which ones would be useful
426 DRUG_TYPES = (
427 ("antidepressant", "antidepressant"),
428 ("conventional_antidepressant", "conventional_antidepressant"),
429 ("ssri", "ssri"),
430 ("non_ssri_modern_antidepressant", "non_ssri_modern_antidepressant"),
431 ("tricyclic_antidepressant", "tricyclic_antidepressant"),
432 (
433 "tetracyclic_and_related_antidepressant",
434 "tetracyclic_and_related_antidepressant",
435 ),
436 ("monoamine_oxidase_inhibitor", "monoamine_oxidase_inhibitor"),
437 ("antipsychotic", "antipsychotic"),
438 ("first_generation_antipsychotic", "first_generation_antipsychotic"),
439 ("second_generation_antipsychotic", "second_generation_antipsychotic"),
440 ("stimulant", "stimulant"),
441 ("anticholinergic", "anticholinergic"),
442 ("benzodiazepine", "benzodiazepine"),
443 ("z_drug", "z_drug"),
444 ("non_benzodiazepine_anxiolytic", "non_benzodiazepine_anxiolytic"),
445 ("gaba_a_functional_agonist", "gaba_a_functional_agonist"),
446 ("gaba_b_functional_agonist", "gaba_b_functional_agonist"),
447 ("mood_stabilizer", "mood_stabilizer"),
448 # Endocrinology
449 ("antidiabetic", "antidiabetic"),
450 ("sulfonylurea", "sulfonylurea"),
451 ("biguanide", "biguanide"),
452 ("glifozin", "glifozin"),
453 ("glp1_agonist", "glp1_agonist"),
454 ("dpp4_inhibitor", "dpp4_inhibitor"),
455 ("meglitinide", "meglitinide"),
456 ("thiazolidinedione", "thiazolidinedione"),
457 # Cardiovascular
458 ("cardiovascular", "cardiovascular"),
459 ("beta_blocker", "beta_blocker"),
460 ("ace_inhibitor", "ace_inhibitor"),
461 ("statin", "statin"),
462 # Respiratory
463 ("respiratory", "respiratory"),
464 ("beta_agonist", "beta_agonist"),
465 # Gastrointestinal
466 ("gastrointestinal", "gastrointestinal"),
467 ("proton_pump_inhibitor", "proton_pump_inhibitor"),
468 ("nonsteroidal_anti_inflammatory", "nonsteroidal_anti_inflammatory"),
469 # Nutritional
470 ("vitamin", "vitamin"),
471 )
472 drug_type = ChoiceField(label="Drug type to find", required=True)
474 def __init__(
475 self, *args, drug_options: List[str] = DRUG_TYPES, **kwargs
476 ) -> None:
477 super().__init__(*args, **kwargs)
478 self.fields["drug_type"].choices = drug_options
481def html_form_date_to_python(text: str) -> datetime.datetime:
482 """
483 Converts a date from the textual form used in HTML forms to a Python
484 ``datetime.datetime`` object.
486 Args:
487 text: text to convert
489 Returns:
490 a ``datetime.datetime``
491 """
492 return datetime.datetime.strptime(text, "%Y-%m-%d")
495def int_validator(text: str) -> str:
496 """
497 Takes text and returns a string version of an integer version of it.
499 Args:
500 text:
502 Returns:
503 the same thing, usually (though any redundant ".0" will be removed)
505 Raises:
506 :exc:`TypeError` or :exc:`ValueError` if something is wrong
508 """
509 return str(int(text)) # may raise ValueError, TypeError
512def float_validator(text: str) -> str:
513 """
514 Takes text and returns a string version of a float version of it.
516 Args:
517 text:
519 Returns:
520 the same thing, usually
522 Raises:
523 :exc:`TypeError` or :exc:`ValueError` if something is wrong
525 """
526 return str(float(text)) # may raise ValueError, TypeError
529class QueryBuilderForm(forms.Form):
530 """
531 Form to build an SQL query using a web interface.
533 Works hand in hand with ``querybuilder.js`` on the client side; q.v.
534 """
536 database = CharField(label="Schema", required=False)
537 schema = CharField(label="Schema", required=True)
538 table = CharField(label="Table", required=True)
539 column = CharField(label="Column", required=True)
540 datatype = CharField(label="Data type", required=True)
541 offer_where = BooleanField(label="Offer WHERE?", required=False)
542 # BooleanField generally needs "required=False", or you can't have False!
543 where_op = CharField(label="WHERE comparison", required=False)
545 date_value = DateField(
546 label="Date value (e.g. 1900-01-31)", required=False
547 )
548 int_value = IntegerField(label="Integer value", required=False)
549 float_value = FloatField(label="Float value", required=False)
550 string_value = CharField(label="String value", required=False)
551 file = FileField(label="File (for IN)", required=False)
553 def __init__(self, *args, **kwargs) -> None:
554 self.file_values_list = [] # type: List[Any]
555 super().__init__(*args, **kwargs)
557 def get_datatype(self) -> Optional[str]:
558 return self.data.get("datatype", None)
560 def is_datatype_unknown(self) -> bool:
561 return self.get_datatype() == QB_DATATYPE_UNKNOWN
563 def offering_where(self) -> bool:
564 if self.is_datatype_unknown():
565 return False
566 return self.data.get("offer_where", False)
568 def get_value_fieldname(self) -> str:
569 datatype = self.get_datatype()
570 if datatype == QB_DATATYPE_INTEGER:
571 return "int_value"
572 if datatype == QB_DATATYPE_FLOAT:
573 return "float_value"
574 if datatype == QB_DATATYPE_DATE:
575 return "date_value"
576 if datatype in QB_STRING_TYPES:
577 return "string_value"
578 if datatype == QB_DATATYPE_UNKNOWN:
579 return ""
580 raise ValueError("Invalid field type")
582 def get_cleaned_where_value(self) -> Any:
583 # Only call this if you've already cleaned/validated the form!
584 return self.cleaned_data[self.get_value_fieldname()]
586 def clean(self) -> None:
587 # Check the WHERE information is sufficient.
588 if "submit_select" in self.data or "submit_select_star" in self.data:
589 # Form submitted via the "Add" method, so no checks required.
590 # https://stackoverflow.com/questions/866272/how-can-i-build-multiple-submit-buttons-django-form # noqa: E501
591 return
592 if not self.offering_where():
593 return
594 cleaned_data = super().clean()
595 if not cleaned_data["where_op"]:
596 self.add_error(
597 "where_op", forms.ValidationError("Must specify comparison")
598 )
600 # No need for a value for NULL-related comparisons. But otherwise:
601 where_op = cleaned_data["where_op"]
602 if where_op not in SQL_OPS_VALUE_UNNECESSARY + SQL_OPS_MULTIPLE_VALUES:
603 # Can't take 0 or many parameters, so need the standard single
604 # value:
605 value_fieldname = self.get_value_fieldname()
606 value = cleaned_data.get(value_fieldname)
607 if not value:
608 self.add_error(
609 value_fieldname,
610 forms.ValidationError("Must specify WHERE condition"),
611 )
613 # ---------------------------------------------------------------------
614 # Special processing for file upload operations
615 # ---------------------------------------------------------------------
616 if where_op not in SQL_OPS_MULTIPLE_VALUES:
617 return
618 fileobj = cleaned_data["file"]
619 # ... is an instance of InMemoryUploadedFile
620 if not fileobj:
621 self.add_error("file", forms.ValidationError("Must specify file"))
622 return
624 datatype = self.get_datatype()
625 if datatype in QB_STRING_TYPES:
626 form_to_python_fn = str
627 elif datatype == QB_DATATYPE_DATE:
628 form_to_python_fn = html_form_date_to_python
629 elif datatype == QB_DATATYPE_INTEGER:
630 form_to_python_fn = int_validator
631 elif datatype == QB_DATATYPE_FLOAT:
632 form_to_python_fn = float_validator
633 else:
634 # Safe defaults
635 form_to_python_fn = str
636 # Or: http://www.dabeaz.com/generators/Generators.pdf
637 self.file_values_list = [] # type: List[Any]
638 for line in fileobj.read().decode("utf8").splitlines():
639 raw_item = line.strip()
640 if not raw_item or raw_item.startswith("#"):
641 continue
642 try:
643 value = form_to_python_fn(raw_item)
644 except (TypeError, ValueError):
645 self.add_error(
646 "file",
647 forms.ValidationError(
648 f"File contains bad value: {raw_item!r}"
649 ),
650 )
651 return
652 self.file_values_list.append(value)
653 if not self.file_values_list:
654 self.add_error(
655 "file", forms.ValidationError("No values found in file")
656 )
659class ManualPeQueryForm(forms.Form):
660 """
661 Simple form for the "manual" section of the "Build Patient Explorer" page.
663 Allows the user to enter raw SQL.
664 """
666 sql = CharField(
667 required=False, widget=forms.Textarea(attrs={"rows": 20, "cols": 80})
668 )