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

1""" 

2crate_anon/crateweb/research/forms.py 

3 

4=============================================================================== 

5 

6 Copyright (C) 2015, University of Cambridge, Department of Psychiatry. 

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

8 

9 This file is part of CRATE. 

10 

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. 

15 

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. 

20 

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/>. 

23 

24=============================================================================== 

25 

26**Django forms for the research site.** 

27 

28""" 

29 

30import datetime 

31import logging 

32from typing import Any, Dict, List, Optional, Type 

33 

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) 

49 

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) 

63 

64log = logging.getLogger(__name__) 

65 

66 

67class AddQueryForm(ModelForm): 

68 """ 

69 Form to add or edit an SQL 

70 :class:`crate_anon.crateweb.research.models.Query`. 

71 """ 

72 

73 class Meta: 

74 model = Query 

75 fields = ["sql"] 

76 widgets = { 

77 "sql": forms.Textarea(attrs={"rows": 20, "cols": 80}), 

78 } 

79 

80 

81class BlankQueryForm(ModelForm): 

82 """ 

83 Unused? For :class:`crate_anon.crateweb.research.models.Query`. 

84 """ 

85 

86 class Meta: 

87 model = Query 

88 fields = [] # type: List[str] 

89 

90 

91class AddHighlightForm(ModelForm): 

92 """ 

93 Form to add/edit a :class:`crate_anon.crateweb.research.models.Highlight`. 

94 """ 

95 

96 class Meta: 

97 model = Highlight 

98 fields = ["colour", "text"] 

99 

100 

101class BlankHighlightForm(ModelForm): 

102 """ 

103 Unused? For :class:`crate_anon.crateweb.research.models.Highlight`. 

104 """ 

105 

106 class Meta: 

107 model = Highlight 

108 fields = [] # type: List[str] 

109 

110 

111class DatabasePickerForm(forms.Form): 

112 """ 

113 Form to choose a research database. 

114 """ 

115 

116 database = ChoiceField(label="Database", required=True) 

117 

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] 

130 

131 

132class PidLookupForm(forms.Form): 

133 """ 

134 Form to look up patient IDs from RIDs, MRIDs, and/or TRIDs. 

135 

136 For the RDBM. 

137 """ 

138 

139 rids = MultipleWordAreaField(required=False) 

140 mrids = MultipleWordAreaField(required=False) 

141 trids = MultipleIntAreaField(required=False) 

142 

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

159 

160 

161class RidLookupForm(forms.Form): 

162 """ 

163 Form to look up RIDs from PIDs/MPIDs. 

164 

165 For clinicians: "get the RID for my patient". 

166 """ 

167 

168 pids = MultipleWordAreaField(required=False) 

169 mpids = MultipleWordAreaField(required=False) 

170 

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

185 

186 

187DEFAULT_MIN_TEXT_FIELD_LENGTH = 100 

188 

189 

190class FieldPickerInfo: 

191 """ 

192 Describes a database field for when the user is asked to choose one via a 

193 web form. 

194 """ 

195 

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 

203 

204 

205class SQLHelperFindAnywhereForm(forms.Form): 

206 """ 

207 Base class for finding something anywhere in a patient's record. 

208 

209 The user gets to pick 

210 

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 

217 

218 The subclasses then choose what the user should search for. 

219 """ 

220 

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 ) 

243 

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 

257 

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 

275 

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 ) 

285 

286 

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)) 

361 

362 

363class SQLHelperTextAnywhereForm(SQLHelperFindAnywhereForm): 

364 """ 

365 Form for "find text anywhere in a patient's record". 

366 

367 The user gets to pick 

368 

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 

376 

377 This research-oriented form is then subclassed for clinicians; see 

378 :class:`ClinicianAllTextFromPidForm`. 

379 """ 

380 

381 fragment = CharField(label="String fragment to find", required=True) 

382 

383 def __init__(self, *args, **kwargs) -> None: 

384 super().__init__(*args, **kwargs) 

385 

386 

387class ClinicianAllTextFromPidForm(SQLHelperTextAnywhereForm): 

388 """ 

389 A slightly restricted form of :class:`SQLHelperTextAnywhereForm` for 

390 clinicians. 

391 

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

396 

397 patient_id = CharField(label="ID value", required=True) 

398 

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 

405 

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() 

410 

411 def _check_permits_empty_id_for_blank_id( 

412 self, opt: FieldPickerInfo 

413 ) -> None: 

414 return 

415 

416 

417class SQLHelperDrugTypeForm(SQLHelperFindAnywhereForm): 

418 """ 

419 Form for "find drug of a given type anywhere in a patient's record". 

420 

421 Same as 'SQLHelperTextAnywhereForm' except the user picks a drug type to 

422 search for instead of a string fragment. 

423 """ 

424 

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) 

473 

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 

479 

480 

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. 

485 

486 Args: 

487 text: text to convert 

488 

489 Returns: 

490 a ``datetime.datetime`` 

491 """ 

492 return datetime.datetime.strptime(text, "%Y-%m-%d") 

493 

494 

495def int_validator(text: str) -> str: 

496 """ 

497 Takes text and returns a string version of an integer version of it. 

498 

499 Args: 

500 text: 

501 

502 Returns: 

503 the same thing, usually (though any redundant ".0" will be removed) 

504 

505 Raises: 

506 :exc:`TypeError` or :exc:`ValueError` if something is wrong 

507 

508 """ 

509 return str(int(text)) # may raise ValueError, TypeError 

510 

511 

512def float_validator(text: str) -> str: 

513 """ 

514 Takes text and returns a string version of a float version of it. 

515 

516 Args: 

517 text: 

518 

519 Returns: 

520 the same thing, usually 

521 

522 Raises: 

523 :exc:`TypeError` or :exc:`ValueError` if something is wrong 

524 

525 """ 

526 return str(float(text)) # may raise ValueError, TypeError 

527 

528 

529class QueryBuilderForm(forms.Form): 

530 """ 

531 Form to build an SQL query using a web interface. 

532 

533 Works hand in hand with ``querybuilder.js`` on the client side; q.v. 

534 """ 

535 

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) 

544 

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) 

552 

553 def __init__(self, *args, **kwargs) -> None: 

554 self.file_values_list = [] # type: List[Any] 

555 super().__init__(*args, **kwargs) 

556 

557 def get_datatype(self) -> Optional[str]: 

558 return self.data.get("datatype", None) 

559 

560 def is_datatype_unknown(self) -> bool: 

561 return self.get_datatype() == QB_DATATYPE_UNKNOWN 

562 

563 def offering_where(self) -> bool: 

564 if self.is_datatype_unknown(): 

565 return False 

566 return self.data.get("offer_where", False) 

567 

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

581 

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()] 

585 

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 ) 

599 

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 ) 

612 

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 

623 

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 ) 

657 

658 

659class ManualPeQueryForm(forms.Form): 

660 """ 

661 Simple form for the "manual" section of the "Build Patient Explorer" page. 

662 

663 Allows the user to enter raw SQL. 

664 """ 

665 

666 sql = CharField( 

667 required=False, widget=forms.Textarea(attrs={"rows": 20, "cols": 80}) 

668 )