Coverage for crateweb/consent/lookup_rio.py: 12%
210 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/consent/lookup_rio.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**Functions to look up patient details from various versions of a Servelec
27RiO clinical database.**
29"""
31from typing import Generator, List, Optional, Tuple
33from cardinal_pythonlib.dbfunc import dictfetchall, dictfetchone, genrows
34from django.db import connections
36from crate_anon.crateweb.consent.lookup_common import (
37 get_team_details,
38 pick_best_clinician,
39 SignatoryTitles,
40)
41from crate_anon.crateweb.consent.models import (
42 ClinicianInfoHolder,
43 ConsentMode,
44 PatientLookup,
45)
46from crate_anon.crateweb.consent.utils import to_date
47from crate_anon.preprocess.rio_constants import (
48 CRATE_COL_RIO_NUMBER,
49 RCEP_COL_PATIENT_ID,
50)
53# =============================================================================
54# Look up patient IDs
55# =============================================================================
57# -----------------------------------------------------------------------------
58# CPFT RiO (raw -> preprocessed by CRATE)
59# -----------------------------------------------------------------------------
62def lookup_cpft_rio_crate_preprocessed(
63 lookup: PatientLookup, decisions: List[str], secret_decisions: List[str]
64) -> None:
65 """
66 Look up patient details from a CRATE-preprocessed RiO database.
68 Args:
69 lookup: a :class:`crate_anon.crateweb.consent.models.PatientLookup`
70 decisions: list of human-readable decisions; will be modified
71 secret_decisions: list of human-readable decisions containing secret
72 (identifiable) information; will be modified
74 Here, we use the version of RiO preprocessed by the CRATE preprocessor.
75 This is almost identical to the RCEP version, saving us some thought and
76 lots of repetition of complex JOIN code to deal with the raw RiO database.
78 However, the CRATE preprocessor does this with views. We would need to
79 index the underlying tables; however, the CRATE processor has also done
80 this for us for the lookup tables, so we don't need so many.
82 .. code-block:: sql
84 USE my_database_name;
86 CREATE INDEX _idx_cdd_nhs ON ClientIndex (NNN); -- already in RiO source
88 CREATE INDEX _idx_cnh_id ON ClientName (ClientID); -- already in RiO source
89 CREATE INDEX _idx_cnh_eff ON ClientName (EffectiveDate); -- ignored
90 CREATE INDEX _idx_cnh_end ON ClientName (EndDate); -- ignored
92 CREATE INDEX _idx_cah_id ON ClientAddress (ClientID); -- already in RiO source as part of composite index
93 CREATE INDEX _idx_cah_from ON ClientAddress (FromDate); -- ignored
94 CREATE INDEX _idx_cah_to ON ClientAddress (ToDate); -- ignored
96 CREATE INDEX _idx_cch_id ON ClientTelecom (ClientID); -- already in RiO source as part of composite index
98 CREATE INDEX _idx_cgh_id ON ClientHealthCareProvider (ClientID); -- already in RiO source
99 CREATE INDEX _idx_cgh_from ON ClientHealthCareProvider (FromDate); -- ignored
100 CREATE INDEX _idx_cgh_to ON ClientHealthCareProvider (ToDate); -- ignored
102 CREATE INDEX _idx_cc_id ON CPACareCoordinator (ClientID); -- preprocessor adds this
103 CREATE INDEX _idx_cc_start ON CPACareCoordinator (StartDate); -- ignored
104 CREATE INDEX _idx_cc_end ON CPACareCoordinator (EndDate); -- ignored
106 CREATE INDEX _idx_ref_id ON AmsReferral (ClientID); -- already in RiO source as part of composite index
107 CREATE INDEX _idx_ref_recv ON AmsReferral (ReferralReceivedDate); -- ignored
108 CREATE INDEX _idx_ref_removal ON AmsReferral (RemovalDateTime); -- ignored
110 CREATE INDEX _idx_rsh_id ON AmsReferralAllocation (ClientID); -- already in RiO source as part of composite index
111 CREATE INDEX _idx_rsh_start ON AmsReferralAllocation (StartDate); -- ignored
112 CREATE INDEX _idx_rsh_end ON AmsReferralAllocation (EndDate); -- ignored
114 CREATE INDEX _idx_rth_id ON AmsReferralTeam (ClientID); -- already in RiO source as part of composite index
115 CREATE INDEX _idx_rth_start ON AmsReferralTeam (StartDate); -- ignored
116 CREATE INDEX _idx_rth_end ON AmsReferralTeam (EndDate); -- ignored
118 ... or alternative RiO number indexes on CRATE_COL_RIO_NUMBER field.
120 Then, the only field name differences from RCEP are:
122 .. code-block:: none
124 Client_Name_History.End_Date -- not End_Date_
125 """ # noqa: E501
126 lookup_cpft_rio_generic(
127 lookup, decisions, secret_decisions, as_crate_not_rcep=True
128 )
131# -----------------------------------------------------------------------------
132# CPFT RiO as preprocessed by Servelec RCEP tool
133# -----------------------------------------------------------------------------
136def lookup_cpft_rio_rcep(
137 lookup: PatientLookup, decisions: List[str], secret_decisions: List[str]
138) -> None:
139 """
140 Look up patient details from a RiO database that's been preprocessed
141 through Servelec's RCEP (RiO CRIS Extraction Program) tool.
143 Args:
144 lookup: a :class:`crate_anon.crateweb.consent.models.PatientLookup`
145 decisions: list of human-readable decisions; will be modified
146 secret_decisions: list of human-readable decisions containing secret
147 (identifiable) information; will be modified
149 **RiO notes, 2015-05-19**
151 ... ADDENDUM 2017-02-27: this is the RiO database as modified by Servelec's
152 RiO CRIS Extraction Program (RCEP). See also lookup_cpft_rio_raw().
154 For speed, RiO-RCEP needs these indexes:
156 .. code-block:: sql
158 USE my_database_name;
160 CREATE INDEX _idx_cdd_nhs ON Client_Demographic_Details (NHS_Number);
162 CREATE INDEX _idx_cnh_id ON Client_Name_History (Client_ID);
163 CREATE INDEX _idx_cnh_eff ON Client_Name_History (Effective_Date);
164 CREATE INDEX _idx_cnh_end ON Client_Name_History (End_Date_);
166 CREATE INDEX _idx_cah_id ON Client_Address_History (Client_ID);
167 CREATE INDEX _idx_cah_from ON Client_Address_History (Address_From_Date);
168 CREATE INDEX _idx_cah_to ON Client_Address_History (Address_To_Date);
170 CREATE INDEX _idx_cch_id ON Client_Communications_History (Client_ID);
172 CREATE INDEX _idx_cgh_id ON Client_GP_History (Client_ID);
173 CREATE INDEX _idx_cgh_from ON Client_GP_History (GP_From_Date);
174 CREATE INDEX _idx_cgh_to ON Client_GP_History (GP_To_Date);
176 CREATE INDEX _idx_cc_id ON CPA_CareCoordinator (Client_ID);
177 CREATE INDEX _idx_cc_start ON CPA_CareCoordinator (Start_Date);
178 CREATE INDEX _idx_cc_end ON CPA_CareCoordinator (End_Date);
180 CREATE INDEX _idx_ref_id ON Main_Referral_Data (Client_ID);
181 CREATE INDEX _idx_ref_recv ON Main_Referral_Data (Referral_Received_Date);
182 CREATE INDEX _idx_ref_removal ON Main_Referral_Data (Removal_DateTime);
184 CREATE INDEX _idx_rsh_id ON Referral_Staff_History (Client_ID);
185 CREATE INDEX _idx_rsh_start ON Referral_Staff_History (Start_Date);
186 CREATE INDEX _idx_rsh_end ON Referral_Staff_History (End_Date);
188 CREATE INDEX _idx_rth_id ON Referral_Team_History (Client_ID);
189 CREATE INDEX _idx_rth_start ON Referral_Team_History (Start_Date);
190 CREATE INDEX _idx_rth_end ON Referral_Team_History (End_Date);
192 -- CREATE INDEX _idx_rth_teamdesc ON Referral_Team_History (Team_Description);
193 """ # noqa: E501
194 lookup_cpft_rio_generic(
195 lookup, decisions, secret_decisions, as_crate_not_rcep=False
196 )
199# -----------------------------------------------------------------------------
200# CPFT RiO: function that copes with either the RCEP or the CRATE version,
201# which are extremely similar.
202# -----------------------------------------------------------------------------
205def lookup_cpft_rio_generic(
206 lookup: PatientLookup,
207 decisions: List[str],
208 secret_decisions: List[str],
209 as_crate_not_rcep: bool,
210) -> None:
211 """
212 Look up patient details from a RiO database, either as a CRATE-processed
213 or an RCEP-processed version. (They are very similar.)
215 Args:
216 lookup: a :class:`crate_anon.crateweb.consent.models.PatientLookup`
217 decisions: list of human-readable decisions; will be modified
218 secret_decisions: list of human-readable decisions containing secret
219 (identifiable) information; will be modified
220 as_crate_not_rcep: is it a CRATE-preprocessed, rather than an
221 RCEP-preprocessed, database?
223 Main:
225 .. code-block:: none
227 Client_Demographic_Details
228 Client_ID -- PK; RiO number; integer in VARCHAR(15) field
229 Date_of_Birth -- DATETIME
230 Date_of_Death -- DATETIME; NULL if not dead
231 Death_Flag -- INT; 0 for alive, 1 for dead
232 Deleted_Flag -- INT; 0 normally; 1 for deleted
233 NHS_Number -- CHAR(10)
234 Gender_Code -- 'F', 'M', 'U', 'X'
235 Gender_Description -- 'Male', 'Female', ...
237 Then, linked to it:
239 .. code-block:: none
241 Client_Name_History
242 Client_ID -- integer in VARCHAR(15)
243 Effective_Date -- DATETIME
244 End_Date_ -- DATETIME, typically NULL
245 -- in the CRATE version, this is End_Date instead
246 Name_Type_Code -- '1' for 'usual name', '2' for 'Alias', '3'
247 for 'Preferred name', '4' for 'Birth name', '5' for
248 'Maiden name', '7' for 'Other', 'CM' for 'Client Merge';
249 NVARCHAR(10)
250 Name_Type_Description -- e.g. 'Usual name', 'Alias'
251 Deleted_Flag -- INT
253 title
254 Given_Name_1 -- through to Given_Name_5
255 Family_Name
256 suffix
257 ...
259 Client_Address_History
260 Client_ID -- integer in VARCHAR(15)
261 Address_Type_Code -- e.g. 'PRIMARY' but also 'CA', 'FCH'...
262 Address_Type_Description
263 Address_From_Date -- DATETIME
264 Address_To_Date -- DATETIME; NULL for active ones
266 Address_Line_1
267 Address_Line_2
268 Address_Line_3
269 Address_Line_4
270 Address_Line_5
271 Post_Code
272 ... -- no e-mail address field
274 Client_GP_History
275 Client_ID -- integer in VARCHAR(15)
276 GP_From_Date -- DATETIME
277 GP_To_Date -- DATETIME; NULL for active ones
279 GP_Name -- e.g. 'Smith JT'
280 GP_Practice_Address_Line1
281 GP_Practice_Address_Line2
282 GP_Practice_Address_Line3
283 GP_Practice_Address_Line4
284 GP_Practice_Address_Line5
285 GP_Practice_Post_code
286 ...
288 CPFT clinician details/?discharged info appear to be here:
290 .. code-block:: none
292 CPA_CareCoordinator
293 Client_ID -- integer in VARCHAR(15)
294 Start_Date -- DATETIME
295 End_Date -- DATETIME
296 End_Reason_Code
297 End_Reason_Description
298 End_Reason_National_Code
300 Care_Coordinator_User_title
301 Care_Coordinator_User_first_name
302 Care_Coordinator_User_surname
303 Care_Coordinator_User_email
304 Care_Coordinator_User_Consultant_Flag -- INT; 0 or 1 (or NULL?)
306 Main_Referral_Data
307 Client_ID -- integer in VARCHAR(15)
308 Referral_Received_Date -- DATETIME
309 Removal_DateTime -- DATETIME
310 # Care_Spell_Start_Date
311 # Care_Spell_End_Date -- never non-NULL in our data set
312 # Discharge_HCP -- ??user closing the referral
314 Referred_Consultant_User_title
315 Referred_Consultant_User_first_name
316 Referred_Consultant_User_surname
317 Referred_Consultant_User_email
318 Referred_Consultant_User_Consultant_Flag -- 0, 1, NULL
320 Referral_Staff_History
321 Client_ID -- integer in VARCHAR(15)
322 Start_Date -- DATETIME
323 End_Date -- DATETIME
324 Current_At_Discharge -- INT -- ? -- 1 or NULL
326 HCP_User_title
327 HCP_User_first_name
328 HCP_User_surname
329 HCP_User_email
330 HCP_User_Consultant_Flag -- 0, 1, NULL
332 Referral_Team_History
333 -- similar, but for teams; no individual info
334 Client_ID -- integer in VARCHAR(15)
335 Start_Date -- DATETIME
336 End_Date -- DATETIME
337 Current_At_Discharge -- INT -- ? -- 1 or NULL
339 Team_Code -- NVARCHAR -- e.g. 'TCGMH712'
340 Team_Description -- NVARCHAR -- e.g. 'George Mackenzie'
341 Team_Classification_Group_Code -- NVARCHAR -- e.g. 'FS'
342 Team_Classification_Group_Description -- NVARCHAR -- e.g.
343 'Forensic Service'
345 Not obviously relevant:
347 .. code-block:: none
349 Client_CPA -- records CPA start/end, etc.
350 Client_Professional_Contacts -- empty table!
352 Added 2017-02-27:
354 .. code-block:: none
356 Client_Communications_History -- email/phone
357 Client_ID -- integer in VARCHAR(15)
358 Method_Code -- NVARCHAR(10); '1' for 'Telephone number', '3'
359 for 'Email address', '4' for 'Minicom/textphone number'
360 Method_Description
361 Context_Code -- e.g. '1' for 'Communication address at home',
362 other codes for 'Vacation home...', etc.
363 Context_Description
364 Contact_Details -- NVARCHAR(80)
366 """
367 cursor = connections[lookup.source_db].cursor()
368 rio_number_field = (
369 CRATE_COL_RIO_NUMBER if as_crate_not_rcep else RCEP_COL_PATIENT_ID
370 )
372 # -------------------------------------------------------------------------
373 # RiO/RCEP: 1. Get RiO PK
374 # -------------------------------------------------------------------------
375 cursor.execute(
376 f"""
377 SELECT
378 {rio_number_field}, -- RiO number (PK)
379 -- NHS_Number,
380 Date_of_Birth,
381 Date_of_Death,
382 Death_Flag,
383 -- Deleted_Flag,
384 Gender_Code
385 -- Gender_Description,
386 FROM Client_Demographic_Details
387 WHERE
388 NHS_Number = %s -- CHAR comparison
389 AND (Deleted_Flag IS NULL OR Deleted_Flag = 0)
390 """,
391 [str(lookup.nhs_number)],
392 )
393 # Can't use "NOT Deleted_Flag" with SQL Server; you get
394 # "An expression of non-boolean type specified in a context where a
395 # condition is expected, near 'Deleted_Flag'."
396 # The field is of type INTEGER NULL, but SQL Server won't auto-cast it
397 # to something boolean.
398 rows = dictfetchall(cursor)
399 if not rows:
400 decisions.append(
401 "NHS number not found in Client_Demographic_Details table."
402 )
403 return
404 if len(rows) > 1:
405 decisions.append("Two patients found with that NHS number; aborting.")
406 return
407 row = rows[0]
408 rio_client_id = row[rio_number_field]
409 lookup.pt_local_id_description = "CPFT RiO number"
410 lookup.pt_local_id_number = rio_client_id
411 secret_decisions.append(f"RiO number: {rio_client_id}.")
412 lookup.pt_dob = to_date(row["Date_of_Birth"])
413 lookup.pt_dod = to_date(row["Date_of_Death"])
414 lookup.pt_dead = bool(lookup.pt_dod or row["Death_Flag"])
415 lookup.pt_sex = "?" if row["Gender_Code"] == "U" else row["Gender_Code"]
417 # -------------------------------------------------------------------------
418 # RiO/RCEP: 2. Name
419 # -------------------------------------------------------------------------
420 cursor.execute(
421 f"""
422 SELECT
423 title,
424 Given_Name_1,
425 Family_Name
426 FROM Client_Name_History
427 WHERE
428 {rio_number_field} = %s
429 AND Effective_Date <= GETDATE()
430 AND ({'End_Date' if as_crate_not_rcep else 'End_Date_'} IS NULL
431 OR {'End_Date' if as_crate_not_rcep else 'End_Date_'} > GETDATE())
432 AND (Deleted_Flag IS NULL OR Deleted_Flag = 0)
433 ORDER BY Name_Type_Code
434 """, # noqa: E501
435 [rio_client_id],
436 )
437 row = dictfetchone(cursor)
438 if not row:
439 decisions.append(
440 "No name/address information found in Client_Name_History."
441 )
442 return
443 lookup.pt_found = True
444 lookup.pt_title = row["title"] or ""
445 lookup.pt_first_name = row["Given_Name_1"] or ""
446 lookup.pt_last_name = row["Family_Name"] or ""
447 # Deal with dodgy case
448 lookup.pt_title = lookup.pt_title.title()
449 lookup.pt_first_name = lookup.pt_first_name.title()
450 lookup.pt_last_name = lookup.pt_last_name.title()
452 # -------------------------------------------------------------------------
453 # RiO/RCEP: 3. Address
454 # -------------------------------------------------------------------------
455 cursor.execute(
456 f"""
457 SELECT
458 Address_Line_1,
459 Address_Line_2,
460 Address_Line_3,
461 Address_Line_4,
462 Address_Line_5,
463 Post_Code
464 FROM Client_Address_History
465 WHERE
466 {rio_number_field} = %s
467 AND Address_From_Date <= GETDATE()
468 AND (Address_To_Date IS NULL
469 OR Address_To_Date > GETDATE())
470 ORDER BY CASE WHEN Address_Type_Code = 'PRIMARY' THEN '1'
471 ELSE Address_Type_Code END
472 """,
473 [rio_client_id],
474 )
475 row = dictfetchone(cursor)
476 if not row:
477 decisions.append("No address found in Client_Address_History table.")
478 else:
479 lookup.pt_address_1 = row["Address_Line_1"] or ""
480 lookup.pt_address_2 = row["Address_Line_2"] or ""
481 lookup.pt_address_3 = row["Address_Line_3"] or ""
482 lookup.pt_address_4 = row["Address_Line_4"] or ""
483 lookup.pt_address_5 = row["Address_Line_5"] or ""
484 lookup.pt_address_6 = row["Post_Code"] or ""
486 # -------------------------------------------------------------------------
487 # RiO/RCEP: 3b. Patient's e-mail address
488 # -------------------------------------------------------------------------
489 cursor.execute(
490 f"""
491 SELECT
492 Contact_Details -- an e-mail address if Method_Code = 3
493 FROM Client_Communications_History
494 WHERE
495 {rio_number_field} = %s
496 AND Method_Code = 3 -- e-mail address
497 AND Valid_From <= GETDATE()
498 AND (Valid_To IS NULL
499 OR Valid_To > GETDATE())
500 ORDER BY Context_Code
501 -- 1 = Communication address at home
502 -- 2 = Primary home (after business hours)
503 -- 3 = Vacation home (when person on holiday)
504 -- 4 = Office address
505 -- 6 = Emergency contact
506 -- 8 = Mobile device
507 """,
508 [rio_client_id],
509 )
510 rows = dictfetchall(cursor)
511 if rows:
512 row = rows[0]
513 lookup.pt_email = row["Contact_Details"]
515 # -------------------------------------------------------------------------
516 # RiO/RCEP: 4. GP
517 # -------------------------------------------------------------------------
518 if as_crate_not_rcep:
519 cursor.execute(
520 f"""
521 SELECT
522 GP_Title,
523 GP_Forename,
524 GP_Surname,
525 GP_Practice_Address_Line_1,
526 GP_Practice_Address_Line_2,
527 GP_Practice_Address_Line_3,
528 GP_Practice_Address_Line_4,
529 GP_Practice_Address_Line_5,
530 GP_Practice_Post_Code
531 FROM Client_GP_History
532 WHERE
533 {rio_number_field} = %s
534 AND GP_From_Date <= GETDATE()
535 AND (GP_To_Date IS NULL OR GP_To_Date > GETDATE())
536 """,
537 [rio_client_id],
538 )
539 row = dictfetchone(cursor)
540 if not row:
541 decisions.append("No GP found in Client_GP_History table.")
542 else:
543 lookup.gp_found = True
544 lookup.gp_title = row["GP_Title"] or "Dr"
545 lookup.gp_first_name = row["GP_Forename"] or ""
546 lookup.gp_last_name = row["GP_Surname"] or ""
547 lookup.gp_address_1 = row["GP_Practice_Address_Line_1"] or ""
548 lookup.gp_address_2 = row["GP_Practice_Address_Line_2"] or ""
549 lookup.gp_address_3 = row["GP_Practice_Address_Line_3"] or ""
550 lookup.gp_address_4 = row["GP_Practice_Address_Line_4"] or ""
551 lookup.gp_address_5 = row["GP_Practice_Address_Line_5"] or ""
552 lookup.gp_address_6 = row["GP_Practice_Post_Code"]
553 else:
554 cursor.execute(
555 f"""
556 SELECT
557 GP_Name,
558 GP_Practice_Address_Line1,
559 GP_Practice_Address_Line2,
560 GP_Practice_Address_Line3,
561 GP_Practice_Address_Line4,
562 GP_Practice_Address_Line5,
563 GP_Practice_Post_code
564 FROM Client_GP_History
565 WHERE
566 {rio_number_field} = %s
567 AND GP_From_Date <= GETDATE()
568 AND (GP_To_Date IS NULL OR GP_To_Date > GETDATE())
569 """,
570 [rio_client_id],
571 )
572 row = dictfetchone(cursor)
573 if not row:
574 decisions.append("No GP found in Client_GP_History table.")
575 else:
576 lookup.gp_found = True
577 lookup.set_gp_name_components(
578 row["GP_Name"] or "", decisions, secret_decisions
579 )
580 lookup.gp_address_1 = row["GP_Practice_Address_Line1"] or ""
581 lookup.gp_address_2 = row["GP_Practice_Address_Line2"] or ""
582 lookup.gp_address_3 = row["GP_Practice_Address_Line3"] or ""
583 lookup.gp_address_4 = row["GP_Practice_Address_Line4"] or ""
584 lookup.gp_address_5 = row["GP_Practice_Address_Line5"] or ""
585 lookup.gp_address_6 = row["GP_Practice_Post_code"]
587 # -------------------------------------------------------------------------
588 # RiO/RCEP: 5. Clinician, active v. discharged
589 # -------------------------------------------------------------------------
590 clinicians = [] # type: List[ClinicianInfoHolder]
591 #
592 # (a) Care coordinator?
593 #
594 if as_crate_not_rcep:
595 care_co_title_field = "Care_Coordinator_Title"
596 care_co_forename_field = "Care_Coordinator_First_Name"
597 care_co_surname_field = "Care_Coordinator_Surname"
598 care_co_email_field = "Care_Coordinator_Email"
599 care_co_consultant_flag_field = "Care_Coordinator_Consultant_Flag"
600 care_co_table = "CPA_Care_Coordinator"
601 else:
602 care_co_title_field = "Care_Coordinator_User_title"
603 care_co_forename_field = "Care_Coordinator_User_first_name"
604 care_co_surname_field = "Care_Coordinator_User_surname"
605 care_co_email_field = "Care_Coordinator_User_email"
606 care_co_consultant_flag_field = "Care_Coordinator_User_Consultant_Flag"
607 care_co_table = "CPA_CareCoordinator"
608 cursor.execute(
609 f"""
610 SELECT
611 {care_co_title_field},
612 {care_co_forename_field},
613 {care_co_surname_field},
614 {care_co_email_field},
615 {care_co_consultant_flag_field},
616 Start_Date,
617 End_Date
618 FROM {care_co_table}
619 WHERE
620 {rio_number_field} = %s
621 AND Start_Date <= GETDATE()
622 """,
623 [rio_client_id],
624 )
625 for row in dictfetchall(cursor):
626 clinicians.append(
627 ClinicianInfoHolder(
628 clinician_type=ClinicianInfoHolder.CARE_COORDINATOR,
629 title=row[care_co_title_field] or "",
630 first_name=row[care_co_forename_field] or "",
631 surname=row[care_co_surname_field] or "",
632 email=row[care_co_email_field] or "",
633 signatory_title=SignatoryTitles.CARE_COORDINATOR,
634 is_consultant=bool(row[care_co_consultant_flag_field]),
635 start_date=row["Start_Date"],
636 end_date=row["End_Date"],
637 )
638 )
639 #
640 # (b) Active named consultant referral?
641 #
642 if as_crate_not_rcep:
643 cons_title_field = "Referred_Consultant_Title"
644 cons_forename_field = "Referred_Consultant_First_Name"
645 cons_surname_field = "Referred_Consultant_Surname"
646 cons_email_field = "Referred_Consultant_Email"
647 cons_consultant_flag_field = "Referred_Consultant_Consultant_Flag"
648 referral_table = "Referral"
649 else:
650 cons_title_field = "Referred_Consultant_User_title"
651 cons_forename_field = "Referred_Consultant_User_first_name"
652 cons_surname_field = "Referred_Consultant_User_surname"
653 cons_email_field = "Referred_Consultant_User_email"
654 cons_consultant_flag_field = "Referred_Consultant_User_Consultant_Flag"
655 referral_table = "Main_Referral_Data"
656 cursor.execute(
657 f"""
658 SELECT
659 {cons_title_field},
660 {cons_forename_field},
661 {cons_surname_field},
662 {cons_email_field},
663 {cons_consultant_flag_field},
664 Referral_Received_Date,
665 Removal_DateTime
666 FROM {referral_table}
667 WHERE
668 {rio_number_field} = %s
669 AND Referral_Received_Date <= GETDATE()
670 """,
671 [rio_client_id],
672 )
673 for row in dictfetchall(cursor):
674 clinicians.append(
675 ClinicianInfoHolder(
676 clinician_type=ClinicianInfoHolder.CONSULTANT,
677 title=row[cons_title_field] or "",
678 first_name=row[cons_forename_field] or "",
679 surname=row[cons_surname_field] or "",
680 email=row[cons_email_field] or "",
681 signatory_title=SignatoryTitles.CONS_PSYCHIATRIST,
682 is_consultant=bool(row[cons_consultant_flag_field]),
683 # ... would be odd if this were not true!
684 start_date=row["Referral_Received_Date"],
685 end_date=row["Removal_DateTime"],
686 )
687 )
688 #
689 # (c) Active other named staff referral?
690 #
691 if as_crate_not_rcep:
692 hcp_title_field = "HCP_Title"
693 hcp_forename_field = "HCP_First_Name"
694 hcp_surname_field = "HCP_Surname"
695 hcp_email_field = "HCP_Email"
696 hcp_consultant_flag_field = "HCP_Consultant_Flag"
697 else:
698 hcp_title_field = "HCP_User_title"
699 hcp_forename_field = "HCP_User_first_name"
700 hcp_surname_field = "HCP_User_surname"
701 hcp_email_field = "HCP_User_email"
702 hcp_consultant_flag_field = "HCP_User_Consultant_Flag"
703 cursor.execute(
704 f"""
705 SELECT
706 {hcp_title_field},
707 {hcp_forename_field},
708 {hcp_surname_field},
709 {hcp_email_field},
710 {hcp_consultant_flag_field},
711 Start_Date,
712 End_Date
713 FROM Referral_Staff_History
714 WHERE
715 {rio_number_field} = %s
716 AND Start_Date <= GETDATE()
717 """,
718 [rio_client_id],
719 )
720 for row in dictfetchall(cursor):
721 clinicians.append(
722 ClinicianInfoHolder(
723 clinician_type=ClinicianInfoHolder.HCP,
724 title=row[hcp_title_field] or "",
725 first_name=row[hcp_forename_field] or "",
726 surname=row[hcp_surname_field] or "",
727 email=row[hcp_email_field] or "",
728 signatory_title=SignatoryTitles.CLINICIAN,
729 is_consultant=bool(row[hcp_consultant_flag_field]),
730 start_date=row["Start_Date"],
731 end_date=row["End_Date"],
732 )
733 )
734 #
735 # (d) Active team referral?
736 #
737 cursor.execute(
738 f"""
739 SELECT
740 Team_Description,
741 Start_Date,
742 End_Date
743 FROM Referral_Team_History
744 WHERE
745 {rio_number_field} = %s
746 AND Start_Date <= GETDATE()
747 """,
748 [rio_client_id],
749 )
750 for row in dictfetchall(cursor):
751 team_info = get_team_details(
752 team_name=row["Team_Description"],
753 start_date=row["Start_Date"],
754 end_date=row["End_Date"],
755 decisions=decisions,
756 )
757 clinicians.append(team_info)
758 # We append it even if we can't find a representative, because it still
759 # carries information about whether the patient is discharged or not.
761 # Re CLINICIAN ADDRESSES:
762 # Candidate tables in RiO:
763 # - OrgContactAddress +/- OrgContactAddressHistory
764 # - OrgOrganisation
765 # - GenPerson <-- THIS. From GenHCP: "This table contains about all HCPs
766 # registered in RiO. HCP’s personal details (name, address etc.) are
767 # stored in GenPerson.
768 # - ??GenLocation; ??GenNHSLocation
769 #
770 # So, GenPerson is correct. However, in CPFT, when we
771 # SELECT * FROM GenPerson WHERE AddressLine2 IS NOT NULL
772 # we get lots of things saying "Agency Staff", "leaves Trust 17/02/15",
773 # "changed name from Smith", "Medical student", and so on.
774 #
775 # Therefore, our source is simply duff; people are using the fields for
776 # a different purpose.
777 # Therefore, the set_from_clinician_info_holder() function will default
778 # to the RDBM's address.
780 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
781 # Now pick a clinician.
782 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
784 pick_best_clinician(lookup, clinicians, decisions)
787# =============================================================================
788# Look up choices about research consent
789# =============================================================================
791# Constant strings used in database:
792ADULT_WITH_CAPACITY_TEXT = "16 or over, has capacity"
793ADULT_WITH_CAPACITY_CODE = "a"
794ADULT_LACKS_CAPACITY_TEXT = "16 or over, lacks capacity"
795ADULT_LACKS_CAPACITY_CODE = "b"
796CHILD_PARENT_TEXT = "Under 16, parent/guardian consent"
797CHILD_PARENT_CODE = "c"
798CHILD_GILLICK_TEXT = "Under 16, “Gillick competent”"
799CHILD_GILLICK_CODE = "d" # by inference; none live yet in raw20171128 DB
800DECISION_METHOD_CODE_TO_TEXT = {
801 ADULT_WITH_CAPACITY_CODE: ADULT_WITH_CAPACITY_TEXT,
802 ADULT_LACKS_CAPACITY_CODE: ADULT_LACKS_CAPACITY_TEXT,
803 CHILD_PARENT_CODE: CHILD_PARENT_TEXT,
804 CHILD_GILLICK_CODE: CHILD_GILLICK_TEXT,
805}
806NOT_APPLICABLE_UPPER = "N/A"
809def get_latest_consent_mode_from_rio_generic(
810 nhs_number: int,
811 source_db: str,
812 decisions: List[str],
813 raw_rio: bool = False,
814 cpft_datamart: bool = False,
815) -> Optional[ConsentMode]:
816 """
817 Returns the latest consent mode for a patient, from some style of RiO
818 database.
820 Args:
821 nhs_number: NHS number
822 source_db: the type of the source database; see
823 :class:`crate_anon.crateweb.config.constants.ClinicalDatabaseType`
824 decisions: list of human-readable decisions; will be modified
825 raw_rio: is the source database a raw copy of RiO?
826 cpft_datamart: is the source database the version from the CPFT
827 data warehouse?
829 Returns:
830 a :class:`crate_anon.crateweb.consent.models.ConsentMode`, or ``None``
832 Shared function as very similar for the various copies of RiO data.
834 In raw RiO at CPFT, the traffic-light table is UserAssessConsentrd.
835 This is processed regularly into the CPFT Data Warehouse, so that
836 contains very fresh data and is a good choice.
838 For CPFT's custom consent mode, built into RiO v6, the table copy in
839 the Data Warehouse is:
841 .. code-block:: sql
843 SELECT
844 [ClientID] -- VARCHAR(15) NOT NULL; RiO number as text
845 ,[AssessmentDate] -- DATETIME
846 ,[ReferralNumber] -- NVARCHAR(20); a small integer as text
847 ,[ResearchContact] -- NVARCHAR(20); 'RED', 'YELLOW', 'GREEN'
848 ,[OptOut]
849 -- BIT; 1 for opt out; NULL (or potentially 0?) for not
850 -- opted out
851 ,[OptOutFromMedicalResearch_AfterDetailsRemoved]
852 -- VARCHAR(1); 'Y' for opt out; 9 for not opted out
853 -- identical information to OptOut; OptOut is the simpler
854 ,[PersonActingonBehalf_of_Patient]
855 -- NTEXT, e.g. 'Mr Smith' or NULL
856 ,[PersonActingonBehalf_of_Patient_Relation]
857 -- NTEXT, e.g. 'Husband' or NULL or 'N/A' or 'n/a'
858 ,[Personactingonbehalf_address]
859 -- NTEXT, e.g. an address or NULL or 'N/A' or 'n/a'
860 ,[WhoMakesDecisionFor_Patient]
861 -- NVARCHAR(40), e.g. one of:
862 -- '16 or over, has capacity'
863 -- '16 or over, lacks capacity'
864 -- 'Under 16, “Gillick competent”'
865 -- note left/right double quotes
866 -- 'Under 16, parent/guardian consent'
867 FROM [CPFT_DATAMART].[dbo].[ConsentToResearch]
868 -- NB this is on a different CPFT server; see databases.txt
870 Note also: the CPFT_DATAMART database does not provide patient-
871 identifiable information, except PatientOverview_RiO:
873 .. code-block:: sql
875 SELECT
876 [NHSNumber] -- VARCHAR(15); NHS number as text
877 ,[ClientID] -- VARCHAR(15) NOT NULL; RiO number as text
878 ,[PatientName] -- VARCHAR(202); e.g. 'Smith, John'
879 ,[Surname] -- VARCHAR(100); e.g. 'Smith'
880 ,[DOB] -- DATETIME; time part is zero
881 ,[TeamStartDate] -- DATETIME
882 ,[TeamName] -- NVARCHAR(50)
883 ,[ReferralNumber] -- INT NOT NULL; a small integer
884 ,[LastAttendedApptDate_WithTeam] -- DATE
885 ,[FutureAppts_WithTeam] -- DATE; just one despite the name
886 ,[HCPs]
887 -- NVARCHAR(MAX); semicolon-delimited list; e.g.
888 -- '; Alice Smith'
889 -- '; Alice Smith; Bob Jones'
890 -- '; Unknown Consultant'
891 FROM [CPFT_DATAMART].[dbo].[PatientOverviewRiO]
893 In raw RiO at CPFT, the traffic-light table is UserAssessConsentrd.
895 .. code-block:: sql
897 SELECT
898 [ClientID] -- VARCHAR(15); RiO number as text
899 ,[AssessmentDate] -- DATETIME
900 ,[system_ValidationData] -- NTEXT; XML style
901 ,[ResearchContact] -- NVARCHAR(20); 'RED', 'YELLOW', 'GREEN', NULL
902 ,[useemail] -- BIT (1, 0, NULL)
903 ,[optout] -- BIT
904 ,[capname] -- NTEXT
905 ,[capaddress] -- NTEXT
906 ,[caprelation] -- NTEXT
907 ,[capacity] -- VARCHAR(20); e.g. 'a', 'b'
908 ,[type12_NoteID] -- INT NOT NULL; small integer
909 ,[type12_OriginalNoteID] -- INT; usually NULL
910 ,[type12_DeletedDate] -- DATETIME; NULL if not deleted
911 ,[type12_UpdatedBy] -- NVARCHAR(20); username
912 ,[type12_UpdatedDate] -- DATETIME
913 ,[formref] -- NVARCHAR(20); typically a small integer as text
915 -- If it's been preprocessed, also these:
916 ,[crate_pk] -- in CRATE preprocessed version only; BIGINT
917 ,[crate_rio_number] -- in CRATE preprocessed version only; BIGINT
918 FROM [dbo].[UserAssessconsentrd]
920 The NHS number table is:
922 .. code-block:: sql
924 SELECT
925 [ClientID] -- VARCHAR(15); RiO number as text
926 ,[NNN] -- CHAR(10); NHS number as text
927 -- and lots more
928 FROM [dbo].ClientIndex]
930 """
931 assert (
932 sum([raw_rio, cpft_datamart]) == 1
933 ), "Specify exactly one database type to look up from"
934 if raw_rio:
935 sql = """
936 SELECT TOP 1 -- guaranteed to be running SQL Server
937 ci.NNN AS nhs_number_text,
938 ci.ClientID AS rio_number_text,
939 cr.AssessmentDate AS decision_date,
940 cr.ResearchContact AS traffic_light,
941 -- 'RED', 'YELLOW', 'GREEN', NULL
942 cr.useemail AS use_email,
943 cr.optout AS opt_out, -- 1, 0 (possibly), NULL
944 cr.capacity AS decision_method_code,
945 cr.capname AS representative_name,
946 cr.caprelation AS representative_relation
947 FROM
948 UserAssessconsentrd AS cr
949 INNER JOIN
950 ClientIndex AS ci
951 ON cr.ClientID = ci.ClientID
952 WHERE
953 ci.NNN = %s -- string comparison
954 ORDER BY
955 cr.AssessmentDate DESC
956 """
957 elif cpft_datamart:
958 # Old, discarded 2018-06-28:
959 _ = """
960 SELECT TOP 1 -- guaranteed to be running SQL Server
961 po.NHSNumber AS nhs_number_text,
962 po.ClientID AS rio_number_text,
963 cr.AssessmentDate AS decision_date,
964 cr.ResearchContact AS traffic_light,
965 -- 'RED', 'YELLOW', 'GREEN', NULL
966 0 AS use_email, -- not in CPFT data warehouse copy
967 cr.OptOut AS opt_out, -- 1, 0 (possibly), NULL
968 cr.WhoMakesDecisionFor_Patient AS decision_method,
969 cr.PersonActingonBehalf_of_Patient AS representative_name,
970 cr.PersonActingonBehalf_of_Patient_Relation AS representative_relation
971 FROM
972 ConsentToResearch AS cr
973 INNER JOIN
974 PatientOverviewRiO AS po
975 ON cr.ClientID = po.ClientID
976 WHERE
977 po.NHSNumber = %s -- string comparison
978 ORDER BY
979 cr.AssessmentDate DESC
980 """ # noqa: E501
981 # BEWARE "%s" IN SQL COMMENTS! The database backend will crash because
982 # the number of substituted parameters will be wrong.
983 # New as of 2018-06-28:
984 sql = """
985 SELECT TOP 1 -- guaranteed to be running SQL Server
986 cr.NHSNumber AS nhs_number_text,
987 cr.ClientID AS rio_number_text,
988 cr.AssessmentDate AS decision_date,
989 cr.ResearchContact AS traffic_light,
990 -- 'RED', 'YELLOW', 'GREEN', NULL
991 0 AS use_email, -- not in CPFT data warehouse copy
992 cr.OptOut AS opt_out, -- 1, 0 (possibly), NULL
993 cr.WhoMakesDecisionFor_Patient AS decision_method,
994 cr.PersonActingonBehalf_of_Patient AS representative_name,
995 cr.PersonActingonBehalf_of_Patient_Relation AS representative_relation
996 FROM
997 ConsentToResearch AS cr
998 WHERE
999 cr.NHSNumber = %s -- string comparison
1000 ORDER BY
1001 cr.AssessmentDate DESC
1002 """ # noqa: E501
1003 else:
1004 assert False, "Internal bug" # makes type checker happy
1006 cursor = connections[source_db].cursor()
1007 cursor.execute(sql, [str(nhs_number)])
1008 row = dictfetchone(cursor)
1009 if not row:
1010 return None
1012 decision_date = row["decision_date"]
1013 exclude_entirely = row["opt_out"] == 1
1014 use_email = row["use_email"] == 1
1015 representative_name = row["representative_name"]
1017 traffic_light = row["traffic_light"]
1018 if traffic_light:
1019 traffic_light = traffic_light.lower()
1020 if traffic_light not in ConsentMode.VALID_CONSENT_MODES:
1021 decisions.append(
1022 f"Invalid traffic light {traffic_light!r}; ignoring"
1023 )
1024 return None
1026 if raw_rio:
1027 # Raw RiO contains codes.
1028 dmc = row["decision_method_code"]
1029 if dmc not in DECISION_METHOD_CODE_TO_TEXT.keys():
1030 decisions.append(f"Decision method code {dmc!r} unknown; ignoring")
1031 return None
1032 dm = DECISION_METHOD_CODE_TO_TEXT[dmc]
1033 else:
1034 # The CPFT Data Warehouse version contains text.
1035 dm = row["decision_method"]
1036 if dm not in DECISION_METHOD_CODE_TO_TEXT.values():
1037 decisions.append(f"Decision method {dm!r} unknown; ignoring")
1038 return None
1040 decision_by_other = (
1041 representative_name
1042 and representative_name.upper() != NOT_APPLICABLE_UPPER
1043 )
1044 # Compare what follows with decision_valid()
1045 decision_signed_by_patient = False
1046 decision_otherwise_directly_authorized_by_patient = dm in [
1047 ADULT_WITH_CAPACITY_TEXT,
1048 CHILD_GILLICK_TEXT,
1049 CHILD_PARENT_TEXT,
1050 ]
1051 decision_under16_signed_by_parent = dm == CHILD_PARENT_TEXT
1052 decision_under16_signed_by_clinician = dm == CHILD_GILLICK_TEXT
1053 # ... the clinician has had to verify Gillick competence
1054 decision_lack_capacity_signed_by_representative = (
1055 # not strictly "signed", but authorized directly by
1056 dm == ADULT_LACKS_CAPACITY_TEXT
1057 and decision_by_other
1058 )
1059 decision_lack_capacity_signed_by_clinician = (
1060 dm == ADULT_LACKS_CAPACITY_TEXT
1061 ) # ... similarly verified by clinician
1063 cm = ConsentMode(
1064 nhs_number=nhs_number,
1065 created_at=decision_date,
1066 # ... important that this date matches the source, not "now". For
1067 # example, if the clinical record copy comes from time T1, and
1068 # we check it at T3, but then data from T2 comes in later, we
1069 # want to recognize that T2 data is newer than what we have (so
1070 # the CRATE copy should be timestamped T2, not T3).
1071 exclude_entirely=exclude_entirely,
1072 consent_mode=traffic_light,
1073 prefers_email=use_email,
1074 decision_signed_by_patient=decision_signed_by_patient,
1075 decision_otherwise_directly_authorized_by_patient=decision_otherwise_directly_authorized_by_patient, # noqa: E501
1076 decision_under16_signed_by_parent=decision_under16_signed_by_parent, # noqa: E501
1077 decision_under16_signed_by_clinician=decision_under16_signed_by_clinician, # noqa: E501
1078 decision_lack_capacity_signed_by_representative=decision_lack_capacity_signed_by_representative, # noqa: E501
1079 decision_lack_capacity_signed_by_clinician=decision_lack_capacity_signed_by_clinician, # noqa: E501
1080 )
1081 return cm
1084def get_latest_consent_mode_from_rio_cpft_datamart(
1085 nhs_number: int, source_db: str, decisions: List[str]
1086) -> Optional[ConsentMode]:
1087 """
1088 Returns the latest consent mode for a patient from the copy of RiO in
1089 the CPFT data warehouse.
1091 Args:
1092 nhs_number: NHS number
1093 source_db: the type of the source database; see
1094 :class:`crate_anon.crateweb.config.constants.ClinicalDatabaseType`
1095 decisions: list of human-readable decisions; will be modified
1097 Returns:
1098 a :class:`crate_anon.crateweb.consent.models.ConsentMode`, or ``None``
1100 """
1101 return get_latest_consent_mode_from_rio_generic(
1102 nhs_number=nhs_number,
1103 source_db=source_db,
1104 decisions=decisions,
1105 cpft_datamart=True,
1106 )
1109def get_latest_consent_mode_from_rio_raw(
1110 nhs_number: int, source_db: str, decisions: List[str]
1111) -> Optional[ConsentMode]:
1112 """
1113 Returns the latest consent mode for a patient from a raw copy of RiO.
1115 Args:
1116 nhs_number: NHS number
1117 source_db: the type of the source database; see
1118 :class:`crate_anon.crateweb.config.constants.ClinicalDatabaseType`
1119 decisions: list of human-readable decisions; will be modified
1121 Returns:
1122 a :class:`crate_anon.crateweb.consent.models.ConsentMode`, or ``None``
1124 """
1125 return get_latest_consent_mode_from_rio_generic(
1126 nhs_number=nhs_number,
1127 source_db=source_db,
1128 decisions=decisions,
1129 raw_rio=True,
1130 )
1133def gen_opt_out_pids_mpids_rio_generic(
1134 source_db: str, raw_rio: bool = False, cpft_datamart: bool = False
1135) -> Generator[Tuple[str, str], None, None]:
1136 """
1137 Generates PIDs/MPIDs from all patients opting out, from a RiO database of
1138 some sort.
1140 Args:
1141 source_db: the type of the source database; see
1142 :class:`crate_anon.crateweb.config.constants.ClinicalDatabaseType`
1143 raw_rio: is the source database a raw copy of RiO?
1144 cpft_datamart: is the source database the version from the CPFT
1145 data warehouse?
1147 Yields:
1148 tuple: ``rio_number, nhs_number`` for each patient opting out; both are
1149 in string format
1150 """
1151 assert (
1152 sum([raw_rio, cpft_datamart]) == 1
1153 ), "Specify exactly one database type to look up from"
1154 if raw_rio:
1155 sql = """
1156 SELECT
1157 ci.ClientID AS rio_number_text,
1158 ci.NNN AS nhs_number_text
1159 FROM
1160 UserAssessconsentrd AS cr
1161 INNER JOIN
1162 ClientIndex AS ci
1163 ON cr.ClientID = ci.ClientID
1164 WHERE
1165 cr.optout = 1
1166 ORDER BY
1167 cr.ClientID
1168 """
1169 elif cpft_datamart:
1170 sql = """
1171 SELECT
1172 po.ClientID AS rio_number_text,
1173 po.NHSNumber AS nhs_number_text
1174 FROM
1175 ConsentToResearch AS cr
1176 INNER JOIN
1177 PatientOverviewRiO AS po
1178 ON cr.ClientID = po.ClientID
1179 WHERE
1180 cr.OptOut = 1
1181 ORDER BY
1182 cr.ClientID
1183 """
1184 else:
1185 assert False, "Internal bug" # makes type checker happy
1187 cursor = connections[source_db].cursor()
1188 cursor.execute(sql)
1189 for row in genrows(cursor):
1190 pid = row[0] # RiO number, as text
1191 mpid = row[1] # NHS number, as text
1192 yield pid, mpid
1195def gen_opt_out_pids_mpids_rio_cpft_datamart(
1196 source_db: str,
1197) -> Generator[Tuple[str, str], None, None]:
1198 """
1199 Generates PIDs/MPIDs from all patients opting out, from a RiO database that
1200 is the version in the CPFT data warehouse.
1202 Args:
1203 source_db: the type of the source database; see
1204 :class:`crate_anon.crateweb.config.constants.ClinicalDatabaseType`
1206 Yields:
1207 tuple: ``rio_number, nhs_number`` for each patient opting out; both are
1208 in string format
1209 """
1210 return gen_opt_out_pids_mpids_rio_generic(
1211 source_db=source_db, cpft_datamart=True
1212 )
1215def gen_opt_out_pids_mpids_rio_raw(
1216 source_db: str,
1217) -> Generator[Tuple[str, str], None, None]:
1218 """
1219 Generates PIDs/MPIDs from all patients opting out, from a raw RiO database.
1221 Args:
1222 source_db: the type of the source database; see
1223 :class:`crate_anon.crateweb.config.constants.ClinicalDatabaseType`
1225 Yields:
1226 tuple: ``rio_number, nhs_number`` for each patient opting out; both are
1227 in string format
1228 """
1229 return gen_opt_out_pids_mpids_rio_generic(
1230 source_db=source_db, raw_rio=True
1231 )