Source code for crate_anon.crateweb.research.forms
"""
crate_anon/crateweb/research/forms.py
===============================================================================
Copyright (C) 2015, University of Cambridge, Department of Psychiatry.
Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
This file is part of CRATE.
CRATE is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
CRATE is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with CRATE. If not, see <https://www.gnu.org/licenses/>.
===============================================================================
**Django forms for the research site.**
"""
import datetime
import logging
from typing import Any, Dict, List, Optional, Type
from cardinal_pythonlib.django.forms import (
MultipleIntAreaField,
MultipleWordAreaField,
)
from django import forms
from django.forms import (
BooleanField,
CharField,
ChoiceField,
DateField,
FileField,
FloatField,
IntegerField,
ModelForm,
)
from crate_anon.crateweb.research.models import Highlight, Query
from crate_anon.crateweb.research.research_db_info import (
SingleResearchDatabase,
)
from crate_anon.common.sql import (
SQL_OPS_MULTIPLE_VALUES,
SQL_OPS_VALUE_UNNECESSARY,
QB_DATATYPE_DATE,
QB_DATATYPE_FLOAT,
QB_DATATYPE_INTEGER,
QB_DATATYPE_UNKNOWN,
QB_STRING_TYPES,
)
log = logging.getLogger(__name__)
[docs]class AddQueryForm(ModelForm):
"""
Form to add or edit an SQL
:class:`crate_anon.crateweb.research.models.Query`.
"""
class Meta:
model = Query
fields = ["sql"]
widgets = {
"sql": forms.Textarea(attrs={"rows": 20, "cols": 80}),
}
[docs]class BlankQueryForm(ModelForm):
"""
Unused? For :class:`crate_anon.crateweb.research.models.Query`.
"""
class Meta:
model = Query
fields = [] # type: List[str]
[docs]class AddHighlightForm(ModelForm):
"""
Form to add/edit a :class:`crate_anon.crateweb.research.models.Highlight`.
"""
class Meta:
model = Highlight
fields = ["colour", "text"]
[docs]class BlankHighlightForm(ModelForm):
"""
Unused? For :class:`crate_anon.crateweb.research.models.Highlight`.
"""
class Meta:
model = Highlight
fields = [] # type: List[str]
[docs]class DatabasePickerForm(forms.Form):
"""
Form to choose a research database.
"""
database = ChoiceField(label="Database", required=True)
[docs] def __init__(
self, *args, dbinfolist: List[SingleResearchDatabase], **kwargs
) -> None:
"""
Args:
dbinfolist:
list of all
:class:`crate_anon.crateweb.research.research_db_info.SingleResearchDatabase`.
"""
super().__init__(*args, **kwargs)
f = self.fields["database"] # type: ChoiceField
f.choices = [(d.name, d.description) for d in dbinfolist]
[docs]class PidLookupForm(forms.Form):
"""
Form to look up patient IDs from RIDs, MRIDs, and/or TRIDs.
For the RDBM.
"""
rids = MultipleWordAreaField(required=False)
mrids = MultipleWordAreaField(required=False)
trids = MultipleIntAreaField(required=False)
[docs] def __init__(
self, *args, dbinfo: SingleResearchDatabase, **kwargs
) -> None:
"""
Args:
dbinfo:
research database to look up descriptions from, as a
:class:`crate_anon.crateweb.research.research_db_info.SingleResearchDatabase`
"""
super().__init__(*args, **kwargs)
rids = self.fields["rids"] # type: MultipleIntAreaField
mrids = self.fields["mrids"] # type: MultipleIntAreaField
trids = self.fields["trids"] # type: MultipleIntAreaField
rids.label = f"{dbinfo.rid_field}: {dbinfo.rid_description} (RID)"
mrids.label = f"{dbinfo.mrid_field}: {dbinfo.mrid_description} (MRID)"
trids.label = f"{dbinfo.trid_field}: {dbinfo.trid_description} (TRID)"
[docs]class RidLookupForm(forms.Form):
"""
Form to look up RIDs from PIDs/MPIDs.
For clinicians: "get the RID for my patient".
"""
pids = MultipleWordAreaField(required=False)
mpids = MultipleWordAreaField(required=False)
[docs] def __init__(
self, *args, dbinfo: SingleResearchDatabase, **kwargs
) -> None:
"""
Args:
dbinfo:
research database to look up descriptions from, as a
:class:`crate_anon.crateweb.research.research_db_info.SingleResearchDatabase`
"""
super().__init__(*args, **kwargs)
pids = self.fields["pids"] # type: MultipleIntAreaField
mpids = self.fields["mpids"] # type: MultipleIntAreaField
pids.label = f"{dbinfo.pid_description} (PID)"
mpids.label = f"{dbinfo.mpid_description} (MPID)"
DEFAULT_MIN_TEXT_FIELD_LENGTH = 100
[docs]class FieldPickerInfo:
"""
Describes a database field for when the user is asked to choose one via a
web form.
"""
[docs] def __init__(
self, value: str, description: str, type_: Type, permits_empty_id: bool
) -> None:
self.value = value
self.description = description
self.type_ = type_
self.permits_empty_id = permits_empty_id
[docs]class SQLHelperFindAnywhereForm(forms.Form):
"""
Base class for finding something anywhere in a patient's record.
The user gets to pick
- a field name (consistent across tables) representing a patient research
ID (see :class:`FieldPickerInfo`);
- a RID value
- options to restrict which text fields are searched
- an option to use full-text indexing where available
- display options
The subclasses then choose what the user should search for.
"""
fkname = ChoiceField(required=True)
patient_id = CharField(
label="ID value (to restrict to a single patient)", required=False
)
use_fulltext_index = BooleanField(
label="Use full-text indexing where available "
"(faster, but requires whole words)",
required=False,
)
min_length = IntegerField(
label=f"Minimum 'width' of textual field to include "
f"(e.g. {DEFAULT_MIN_TEXT_FIELD_LENGTH})",
min_value=1,
required=True,
)
include_content = BooleanField(
label="Include content from fields where found (slower)",
required=False,
)
include_datetime = BooleanField(
label="Include date/time where known", required=False
)
[docs] def __init__(
self,
*args,
fk_options: List[FieldPickerInfo],
fk_label: str = "Field name containing patient research ID",
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.fk_options = fk_options
# Set the choices available for fkname
f = self.fields["fkname"] # type: ChoiceField
f.choices = [(opt.value, opt.description) for opt in fk_options]
f.label = fk_label
[docs] def clean(self) -> Dict[str, Any]:
cleaned_data = super().clean()
fieldname = cleaned_data.get("fkname")
pidvalue = cleaned_data.get("patient_id")
if fieldname:
opt = next(o for o in self.fk_options if o.value == fieldname)
if pidvalue:
try:
_ = opt.type_(pidvalue)
except (TypeError, ValueError):
raise forms.ValidationError(
f"For field {opt.description!r}, the ID value must be "
f"of type {opt.type_}"
)
else:
self._check_permits_empty_id_for_blank_id(opt)
return cleaned_data
def _check_permits_empty_id_for_blank_id(
self, opt: FieldPickerInfo
) -> None:
# Exists as a function so ClinicianAllTextFromPidForm can override it.
if not opt.permits_empty_id:
raise forms.ValidationError(
f"For this ID type ({opt.value}), "
f"you must specify an ID value"
)
# class SQLHelperTextAnywhereForm(forms.Form):
# """
# Form for "find text anywhere in a patient's record".
#
# The user gets to pick
#
# - a field name (consistent across tables) representing a patient research
# ID (see :class:`FieldPickerInfo`);
# - a RID value
# - details of the text to search for
# - options to restrict which text fields are searched
# - an option to use full-text indexing where available
# - display options
#
# This research-oriented form is then subclassed for clinicians; see
# :class:`ClinicianAllTextFromPidForm`.
# """
# fkname = ChoiceField(required=True)
# patient_id = CharField(label="ID value (to restrict to a single patient)", # noqa: E501
# required=False)
# fragment = CharField(label="String fragment to find", required=True)
# use_fulltext_index = BooleanField(
# label="Use full-text indexing where available "
# "(faster, but requires whole words)",
# required=False)
# min_length = IntegerField(
# label="Minimum 'width' of textual field to include (e.g. {})".format(
# DEFAULT_MIN_TEXT_FIELD_LENGTH
# ),
# min_value=1, required=True)
# include_content = BooleanField(
# label="Include content from fields where found (slower)",
# required=False)
# include_datetime = BooleanField(
# label="Include date/time from where known",
# required=False)
#
# def __init__(
# self,
# *args,
# fk_options: List[FieldPickerInfo],
# fk_label: str = "Field name containing patient research ID",
# **kwargs) -> None:
# super().__init__(*args, **kwargs)
# self.fk_options = fk_options
# # Set the choices available for fkname
# f = self.fields['fkname'] # type: ChoiceField
# f.choices = [(opt.value, opt.description) for opt in fk_options]
# f.label = fk_label
#
# def clean(self) -> Dict[str, Any]:
# cleaned_data = super().clean()
# fieldname = cleaned_data.get("fkname")
# pidvalue = cleaned_data.get("patient_id")
# if fieldname:
# opt = next(o for o in self.fk_options if o.value == fieldname)
# if pidvalue:
# try:
# _ = opt.type_(pidvalue)
# except (TypeError, ValueError):
# raise forms.ValidationError(
# "For field {!r}, the ID value must be of "
# "type {}".format(opt.description, opt.type_))
# else:
# self._check_permits_empty_id_for_blank_id(opt)
# return cleaned_data
#
# def _check_permits_empty_id_for_blank_id(self,
# opt: FieldPickerInfo) -> None:
# # Exists as a function so ClinicianAllTextFromPidForm can override it. # noqa: E501
# if not opt.permits_empty_id:
# raise forms.ValidationError(
# "For this ID type ({}), you must specify an ID "
# "value".format(opt.value))
[docs]class SQLHelperTextAnywhereForm(SQLHelperFindAnywhereForm):
"""
Form for "find text anywhere in a patient's record".
The user gets to pick
- a field name (consistent across tables) representing a patient research
ID (see :class:`FieldPickerInfo`);
- a RID value
- details of the text to search for
- options to restrict which text fields are searched
- an option to use full-text indexing where available
- display options
This research-oriented form is then subclassed for clinicians; see
:class:`ClinicianAllTextFromPidForm`.
"""
fragment = CharField(label="String fragment to find", required=True)
[docs]class ClinicianAllTextFromPidForm(SQLHelperTextAnywhereForm):
"""
A slightly restricted form of :class:`SQLHelperTextAnywhereForm` for
clinicians.
The clinician version always requires an ID (no "patient browsing"; that's
in the domain of research as it might yield patients that aren't being
cared for by this clinician).
"""
patient_id = CharField(label="ID value", required=True)
[docs] def __init__(self, *args, **kwargs) -> None:
super().__init__(
*args, fk_label="Field name containing patient ID", **kwargs
)
inccontent = self.fields["include_content"] # type: BooleanField
incdate = self.fields["include_datetime"] # type: BooleanField
# Hide include_content/include_datetime (always true here)
# inccontent.widget = inccontent.hidden_widget # ... nope!
inccontent.widget = forms.HiddenInput() # yes, this works
incdate.widget = forms.HiddenInput()
def _check_permits_empty_id_for_blank_id(
self, opt: FieldPickerInfo
) -> None:
return
[docs]class SQLHelperDrugTypeForm(SQLHelperFindAnywhereForm):
"""
Form for "find drug of a given type anywhere in a patient's record".
Same as 'SQLHelperTextAnywhereForm' except the user picks a drug type to
search for instead of a string fragment.
"""
# Left these all in because I didn't know which ones would be useful
DRUG_TYPES = (
("antidepressant", "antidepressant"),
("conventional_antidepressant", "conventional_antidepressant"),
("ssri", "ssri"),
("non_ssri_modern_antidepressant", "non_ssri_modern_antidepressant"),
("tricyclic_antidepressant", "tricyclic_antidepressant"),
(
"tetracyclic_and_related_antidepressant",
"tetracyclic_and_related_antidepressant",
),
("monoamine_oxidase_inhibitor", "monoamine_oxidase_inhibitor"),
("antipsychotic", "antipsychotic"),
("first_generation_antipsychotic", "first_generation_antipsychotic"),
("second_generation_antipsychotic", "second_generation_antipsychotic"),
("stimulant", "stimulant"),
("anticholinergic", "anticholinergic"),
("benzodiazepine", "benzodiazepine"),
("z_drug", "z_drug"),
("non_benzodiazepine_anxiolytic", "non_benzodiazepine_anxiolytic"),
("gaba_a_functional_agonist", "gaba_a_functional_agonist"),
("gaba_b_functional_agonist", "gaba_b_functional_agonist"),
("mood_stabilizer", "mood_stabilizer"),
# Endocrinology
("antidiabetic", "antidiabetic"),
("sulfonylurea", "sulfonylurea"),
("biguanide", "biguanide"),
("glifozin", "glifozin"),
("glp1_agonist", "glp1_agonist"),
("dpp4_inhibitor", "dpp4_inhibitor"),
("meglitinide", "meglitinide"),
("thiazolidinedione", "thiazolidinedione"),
# Cardiovascular
("cardiovascular", "cardiovascular"),
("beta_blocker", "beta_blocker"),
("ace_inhibitor", "ace_inhibitor"),
("statin", "statin"),
# Respiratory
("respiratory", "respiratory"),
("beta_agonist", "beta_agonist"),
# Gastrointestinal
("gastrointestinal", "gastrointestinal"),
("proton_pump_inhibitor", "proton_pump_inhibitor"),
("nonsteroidal_anti_inflammatory", "nonsteroidal_anti_inflammatory"),
# Nutritional
("vitamin", "vitamin"),
)
drug_type = ChoiceField(label="Drug type to find", required=True)
[docs] def __init__(
self, *args, drug_options: List[str] = DRUG_TYPES, **kwargs
) -> None:
super().__init__(*args, **kwargs)
self.fields["drug_type"].choices = drug_options
[docs]def html_form_date_to_python(text: str) -> datetime.datetime:
"""
Converts a date from the textual form used in HTML forms to a Python
``datetime.datetime`` object.
Args:
text: text to convert
Returns:
a ``datetime.datetime``
"""
return datetime.datetime.strptime(text, "%Y-%m-%d")
[docs]def int_validator(text: str) -> str:
"""
Takes text and returns a string version of an integer version of it.
Args:
text:
Returns:
the same thing, usually (though any redundant ".0" will be removed)
Raises:
:exc:`TypeError` or :exc:`ValueError` if something is wrong
"""
return str(int(text)) # may raise ValueError, TypeError
[docs]def float_validator(text: str) -> str:
"""
Takes text and returns a string version of a float version of it.
Args:
text:
Returns:
the same thing, usually
Raises:
:exc:`TypeError` or :exc:`ValueError` if something is wrong
"""
return str(float(text)) # may raise ValueError, TypeError
[docs]class QueryBuilderForm(forms.Form):
"""
Form to build an SQL query using a web interface.
Works hand in hand with ``querybuilder.js`` on the client side; q.v.
"""
database = CharField(label="Schema", required=False)
schema = CharField(label="Schema", required=True)
table = CharField(label="Table", required=True)
column = CharField(label="Column", required=True)
datatype = CharField(label="Data type", required=True)
offer_where = BooleanField(label="Offer WHERE?", required=False)
# BooleanField generally needs "required=False", or you can't have False!
where_op = CharField(label="WHERE comparison", required=False)
date_value = DateField(
label="Date value (e.g. 1900-01-31)", required=False
)
int_value = IntegerField(label="Integer value", required=False)
float_value = FloatField(label="Float value", required=False)
string_value = CharField(label="String value", required=False)
file = FileField(label="File (for IN)", required=False)
[docs] def __init__(self, *args, **kwargs) -> None:
self.file_values_list = [] # type: List[Any]
super().__init__(*args, **kwargs)
def get_datatype(self) -> Optional[str]:
return self.data.get("datatype", None)
def is_datatype_unknown(self) -> bool:
return self.get_datatype() == QB_DATATYPE_UNKNOWN
def offering_where(self) -> bool:
if self.is_datatype_unknown():
return False
return self.data.get("offer_where", False)
def get_value_fieldname(self) -> str:
datatype = self.get_datatype()
if datatype == QB_DATATYPE_INTEGER:
return "int_value"
if datatype == QB_DATATYPE_FLOAT:
return "float_value"
if datatype == QB_DATATYPE_DATE:
return "date_value"
if datatype in QB_STRING_TYPES:
return "string_value"
if datatype == QB_DATATYPE_UNKNOWN:
return ""
raise ValueError("Invalid field type")
def get_cleaned_where_value(self) -> Any:
# Only call this if you've already cleaned/validated the form!
return self.cleaned_data[self.get_value_fieldname()]
[docs] def clean(self) -> None:
# Check the WHERE information is sufficient.
if "submit_select" in self.data or "submit_select_star" in self.data:
# Form submitted via the "Add" method, so no checks required.
# https://stackoverflow.com/questions/866272/how-can-i-build-multiple-submit-buttons-django-form # noqa
return
if not self.offering_where():
return
cleaned_data = super().clean()
if not cleaned_data["where_op"]:
self.add_error(
"where_op", forms.ValidationError("Must specify comparison")
)
# No need for a value for NULL-related comparisons. But otherwise:
where_op = cleaned_data["where_op"]
if where_op not in SQL_OPS_VALUE_UNNECESSARY + SQL_OPS_MULTIPLE_VALUES:
# Can't take 0 or many parameters, so need the standard single
# value:
value_fieldname = self.get_value_fieldname()
value = cleaned_data.get(value_fieldname)
if not value:
self.add_error(
value_fieldname,
forms.ValidationError("Must specify WHERE condition"),
)
# ---------------------------------------------------------------------
# Special processing for file upload operations
# ---------------------------------------------------------------------
if where_op not in SQL_OPS_MULTIPLE_VALUES:
return
fileobj = cleaned_data["file"]
# ... is an instance of InMemoryUploadedFile
if not fileobj:
self.add_error("file", forms.ValidationError("Must specify file"))
return
datatype = self.get_datatype()
if datatype in QB_STRING_TYPES:
form_to_python_fn = str
elif datatype == QB_DATATYPE_DATE:
form_to_python_fn = html_form_date_to_python
elif datatype == QB_DATATYPE_INTEGER:
form_to_python_fn = int_validator
elif datatype == QB_DATATYPE_FLOAT:
form_to_python_fn = float_validator
else:
# Safe defaults
form_to_python_fn = str
# Or: http://www.dabeaz.com/generators/Generators.pdf
self.file_values_list = [] # type: List[Any]
for line in fileobj.read().decode("utf8").splitlines():
raw_item = line.strip()
if not raw_item or raw_item.startswith("#"):
continue
try:
value = form_to_python_fn(raw_item)
except (TypeError, ValueError):
self.add_error(
"file",
forms.ValidationError(
f"File contains bad value: {raw_item!r}"
),
)
return
self.file_values_list.append(value)
if not self.file_values_list:
self.add_error(
"file", forms.ValidationError("No values found in file")
)
[docs]class ManualPeQueryForm(forms.Form):
"""
Simple form for the "manual" section of the "Build Patient Explorer" page.
Allows the user to enter raw SQL.
"""
sql = CharField(
required=False, widget=forms.Textarea(attrs={"rows": 20, "cols": 80})
)