-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
refactor QaCase into CrfCase and RequisitionCase
Showing
15 changed files
with
473 additions
and
74 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
from .qa_case import QaCase, QaCaseError | ||
from .crf_case import CrfCase, CrfCaseError | ||
from .crf_subquery import CrfSubquery | ||
from .requisition_case import RequisitionCase | ||
from .requisition_subquery import RequisitionSubquery | ||
from .sql_view_generator import SqlViewGenerator | ||
from .subquery import Subquery | ||
from .subquery_from_dict import subquery_from_dict |
23 changes: 10 additions & 13 deletions
23
edc_qareports/sql_generator/qa_case.py → edc_qareports/sql_generator/crf_case.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,43 +1,40 @@ | ||
from dataclasses import dataclass, field | ||
|
||
import sqlglot | ||
from django.apps import apps as django_apps | ||
from django.db import OperationalError, connection | ||
|
||
from .subquery_from_dict import subquery_from_dict | ||
from .crf_subquery import CrfSubquery | ||
|
||
|
||
class QaCaseError(Exception): | ||
class CrfCaseError(Exception): | ||
pass | ||
|
||
|
||
@dataclass(kw_only=True) | ||
class QaCase: | ||
class CrfCase: | ||
label: str = None | ||
dbtable: str = None | ||
label_lower: str = None | ||
fld_name: str | None = None | ||
where: str | None = None | ||
list_tables: list[tuple[str, str, str]] | None = field(default_factory=list) | ||
|
||
def __post_init__(self): | ||
if self.fld_name is None and self.where is None: | ||
raise QaCaseError("Expected either 'fld_name' or 'where'. Got None for both.") | ||
elif self.fld_name is not None and self.where is not None: | ||
raise QaCaseError("Expected either 'fld_name' or 'where', not both.") | ||
subjectvisit_dbtable: str | None = None | ||
|
||
@property | ||
def sql(self): | ||
return subquery_from_dict([self.__dict__]) | ||
sql = CrfSubquery(**self.__dict__).sql | ||
vendor = "postgres" if connection.vendor.startswith("postgres") else connection.vendor | ||
return sqlglot.transpile(sql, read="mysql", write=vendor)[0] | ||
|
||
@property | ||
def model_cls(self): | ||
return django_apps.get_model(self.label_lower) | ||
|
||
def fetchall(self): | ||
sql = subquery_from_dict([self.__dict__]) | ||
with connection.cursor() as cursor: | ||
try: | ||
cursor.execute(sql) | ||
cursor.execute(self.sql) | ||
except OperationalError as e: | ||
raise QaCaseError(f"{e}. See {self}.") | ||
raise CrfCaseError(f"{e}. See {self}.") | ||
return cursor.fetchall() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
from dataclasses import dataclass, field | ||
from string import Template | ||
|
||
|
||
class CrfSubqueryError(Exception): | ||
pass | ||
|
||
|
||
@dataclass(kw_only=True) | ||
class CrfSubquery: | ||
label: str = None | ||
label_lower: str = None | ||
dbtable: str = None | ||
fld_name: str | None = None | ||
subjectvisit_dbtable: str | None = None | ||
where: str | None = None | ||
list_tables: list[tuple[str, str, str]] | None = field(default_factory=list) | ||
template: Template = field( | ||
init=False, | ||
default=Template( | ||
"select v.subject_identifier, crf.id as original_id, crf.subject_visit_id, " | ||
"crf.report_datetime, crf.site_id, v.visit_code, " | ||
"v.visit_code_sequence, v.schedule_name, crf.modified, " | ||
"'${label_lower}' as label_lower, " | ||
"'${label}' as label, count(*) as records " | ||
"from ${dbtable} as crf " | ||
"left join ${subjectvisit_dbtable} as v on v.id=crf.subject_visit_id " | ||
"${left_joins} " | ||
"where ${where} " | ||
"group by v.subject_identifier, crf.subject_visit_id, crf.report_datetime, " | ||
"crf.site_id, v.visit_code, v.visit_code_sequence, v.schedule_name, crf.modified" | ||
), | ||
) | ||
|
||
def __post_init__(self): | ||
# default where statement if not provided and have fld_name. | ||
if self.where is None and self.fld_name: | ||
self.where = f"crf.{self.fld_name} is null" | ||
if not self.label_lower: | ||
raise CrfSubqueryError("label_lower is required") | ||
if not self.subjectvisit_dbtable: | ||
self.subjectvisit_dbtable = f"{self.label_lower.split('.')[0]}_subjectvisit" | ||
|
||
@property | ||
def left_joins(self) -> str: | ||
"""Add list tbls to access list cols by 'name' instead of 'id'""" | ||
left_join = [] | ||
for opts in self.list_tables or []: | ||
list_field, list_dbtable, alias = opts | ||
left_join.append( | ||
f"left join {list_dbtable} as {alias} on crf.{list_field}={alias}.id" | ||
) | ||
return " ".join(left_join) | ||
|
||
@property | ||
def sql(self): | ||
opts = {k: v for k, v in self.__dict__.items() if v is not None} | ||
opts.update(left_joins=self.left_joins) | ||
try: | ||
sql = self.template.substitute(**opts).replace(";", "") | ||
except KeyError as e: | ||
raise CrfSubqueryError(e) | ||
return sql |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
from dataclasses import dataclass | ||
|
||
from .crf_case import CrfCase | ||
from .requisition_subquery import RequisitionSubquery | ||
|
||
|
||
@dataclass(kw_only=True) | ||
class RequisitionCase(CrfCase): | ||
panel: str = None | ||
subjectrequisition_dbtable: str | None = None | ||
panel_dbtable: str | None = None | ||
|
||
@property | ||
def sql(self): | ||
sql = RequisitionSubquery(**self.__dict__).sql | ||
return sql.format(panel=self.panel) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
from dataclasses import dataclass, field | ||
from string import Template | ||
|
||
from edc_constants.constants import YES | ||
|
||
from .crf_subquery import CrfSubquery | ||
|
||
|
||
class RequisitionSubqueryError(Exception): | ||
pass | ||
|
||
|
||
@dataclass(kw_only=True) | ||
class RequisitionSubquery(CrfSubquery): | ||
"""Generate a SELECT query returning requisitions where | ||
is_drawn=Yes for a given panel but results have not been captured | ||
in the result CRF. | ||
For example requisition exists for panel FBC but results_fbc | ||
CRF does not exist. | ||
""" | ||
|
||
panel: str = None | ||
subjectrequisition_dbtable: str | None = None | ||
panel_dbtable: str | None = None | ||
template: str = field( | ||
init=False, | ||
default=Template( | ||
"select req.subject_identifier, req.id as original_id, req.subject_visit_id, " | ||
"req.report_datetime, req.site_id, v.visit_code, v.visit_code_sequence, " | ||
"v.schedule_name, req.modified, '${label_lower}' as label_lower, " | ||
"'${label}' as label, count(*) as records " | ||
"from ${subjectrequisition_dbtable} as req " | ||
"left join ${dbtable} as crf on req.id=crf.requisition_id " | ||
"left join ${subjectvisit_dbtable} as v on v.id=req.subject_visit_id " | ||
"${left_joins} " | ||
"left join ${panel_dbtable} as panel on req.panel_id=panel.id " | ||
f"where panel.name='${{panel}}' and req.is_drawn='{YES}' and crf.id is null " | ||
"group by req.id, req.subject_identifier, req.subject_visit_id, " | ||
"req.report_datetime, req.site_id, v.visit_code, v.visit_code_sequence, " | ||
"v.schedule_name, req.modified" | ||
), | ||
) | ||
|
||
def __post_init__(self): | ||
# default where statement if not provided and have fld_name. | ||
if self.where is None and self.fld_name: | ||
self.where = f"crf.{self.fld_name} is null" | ||
if not self.label_lower: | ||
raise RequisitionSubqueryError("label_lower is required") | ||
if not self.subjectvisit_dbtable: | ||
self.subjectvisit_dbtable = f"{self.label_lower.split('.')[0]}_subjectvisit" | ||
if not self.subjectrequisition_dbtable: | ||
self.subjectrequisition_dbtable = ( | ||
f"{self.label_lower.split('.')[0]}_subjectrequisition" | ||
) | ||
if not self.panel_dbtable: | ||
self.panel_dbtable = "edc_lab_panel" |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<a title="{{ label|default:"Add CRF"}}" role="button" class="button" href="{{ url }}">Add CRF</a> |
1 change: 1 addition & 0 deletions
1
edc_qareports/templates/edc_qareports/columns/change_button.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<a title="{{ label|default:"Change CRF"}}" role="button" class="button" href="{{ url }}">Change CRF</a> |
2 changes: 1 addition & 1 deletion
2
edc_qareports/templates/edc_qareports/columns/notes_column.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
<a data-toggle="tooltip" title="{{ title }}" href="{{ url }}">{{ label }}</a> | ||
<a data-toggle="tooltip" title="{{ title|default:"Add/Edit a note if pending or cannot be resolved" }}" href="{{ url }}">{{ label }}</a> |
1 change: 1 addition & 0 deletions
1
edc_qareports/templates/edc_qareports/columns/subject_identifier_column.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<a title="{{ title }}" href="{{ url }}?q={{ subject_identifier }}">{{ label|default:subject_identifier }}</a> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
from django.db import models | ||
from django.db.models import PROTECT | ||
from edc_appointment_app.models import SubjectVisit | ||
from edc_crf.model_mixins import CrfStatusModelMixin | ||
from edc_lab.model_mixins import CrfWithRequisitionModelMixin, requisition_fk_options | ||
from edc_lab_panel.panels import fbc_panel | ||
from edc_lab_results.model_mixins import ( | ||
BloodResultsModelMixin, | ||
HaemoglobinModelMixin, | ||
HctModelMixin, | ||
MchcModelMixin, | ||
MchModelMixin, | ||
McvModelMixin, | ||
PlateletsModelMixin, | ||
RbcModelMixin, | ||
WbcModelMixin, | ||
) | ||
from edc_model.models import BaseUuidModel | ||
from edc_sites.model_mixins import SiteModelMixin | ||
|
||
requisition_fk_options.update(to="edc_appointment_app.SubjectRequisition") | ||
|
||
|
||
class BloodResultsFbc( | ||
SiteModelMixin, | ||
CrfWithRequisitionModelMixin, | ||
HaemoglobinModelMixin, | ||
HctModelMixin, | ||
RbcModelMixin, | ||
WbcModelMixin, | ||
PlateletsModelMixin, | ||
MchModelMixin, | ||
MchcModelMixin, | ||
McvModelMixin, | ||
BloodResultsModelMixin, | ||
CrfStatusModelMixin, | ||
BaseUuidModel, | ||
): | ||
lab_panel = fbc_panel | ||
|
||
subject_visit = models.ForeignKey(SubjectVisit, on_delete=PROTECT) | ||
|
||
requisition = models.ForeignKey( | ||
limit_choices_to={"panel__name": fbc_panel.name}, **requisition_fk_options | ||
) | ||
|
||
def get_summary(self): | ||
return "" | ||
|
||
class Meta(CrfStatusModelMixin.Meta, BaseUuidModel.Meta): | ||
verbose_name = "Blood Result: FBC" | ||
verbose_name_plural = "Blood Results: FBC" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,22 +1,189 @@ | ||
from django.test import TestCase | ||
import datetime as dt | ||
from zoneinfo import ZoneInfo | ||
|
||
import time_machine | ||
from django.db import OperationalError, connection | ||
from django.test import TestCase, override_settings | ||
from edc_appointment.models import Appointment | ||
from edc_appointment.tests.helper import Helper | ||
from edc_appointment_app.models import CrfOne, Panel, SubjectRequisition, SubjectVisit | ||
from edc_appointment_app.tests import AppointmentAppTestCaseMixin | ||
from edc_auth.get_app_codenames import get_app_codenames | ||
from edc_constants.constants import YES | ||
from edc_lab_panel.constants import FBC | ||
from edc_reportable import TEN_X_9_PER_LITER | ||
|
||
from edc_qareports.sql_generator import CrfCase, CrfCaseError, RequisitionCase | ||
from edc_qareports.sql_generator.crf_subquery import CrfSubqueryError | ||
|
||
from ..models import BloodResultsFbc | ||
|
||
utc_tz = ZoneInfo("UTC") | ||
|
||
|
||
@override_settings(SITE_ID=10) | ||
@time_machine.travel(dt.datetime(2019, 6, 11, 8, 00, tzinfo=utc_tz)) | ||
class TestQA(AppointmentAppTestCaseMixin, TestCase): | ||
helper_cls = Helper | ||
|
||
class TestQA(TestCase): | ||
def create_unscheduled_appointments(self, appointment): | ||
pass | ||
|
||
def test_codenames(self): | ||
"""Assert default codenames. | ||
Note: in tests this will include codenames for test models. | ||
""" | ||
codenames = get_app_codenames("edc_qareports") | ||
codenames.sort() | ||
expected_codenames = [ | ||
"edc_qareports.add_bloodresultsfbc", | ||
"edc_qareports.add_edcpermissions", | ||
"edc_qareports.add_note", | ||
"edc_qareports.change_bloodresultsfbc", | ||
"edc_qareports.change_edcpermissions", | ||
"edc_qareports.change_note", | ||
"edc_qareports.delete_bloodresultsfbc", | ||
"edc_qareports.delete_edcpermissions", | ||
"edc_qareports.delete_note", | ||
"edc_qareports.view_bloodresultsfbc", | ||
"edc_qareports.view_edcpermissions", | ||
"edc_qareports.view_note", | ||
"edc_qareports.view_qareportlog", | ||
"edc_qareports.view_qareportlogsummary", | ||
] | ||
self.assertEqual(codenames, expected_codenames) | ||
|
||
def test_crfcase_invalid(self): | ||
crf_case = CrfCase() | ||
# sql template requires a complete dictionary of values | ||
self.assertRaises(CrfSubqueryError, getattr, crf_case, "sql") | ||
|
||
def test_fldname_crfcase(self): | ||
"""Assert generates valid SQL or raises""" | ||
# raise for bad fld_name | ||
crf_case = CrfCase( | ||
label="F1 is missing", | ||
dbtable="edc_appointment_app_crfone", | ||
label_lower="edc_appointment_app.crfone", | ||
fld_name="bad_fld_name", | ||
) | ||
|
||
try: | ||
with connection.cursor() as cursor: | ||
cursor.execute(crf_case.sql) | ||
except OperationalError as e: | ||
self.assertIn("bad_fld_name", str(e)) | ||
else: | ||
self.fail("OperationalError not raised for invalid fld_name.") | ||
|
||
# ok | ||
crf_case = CrfCase( | ||
label="F1 is missing", | ||
dbtable="edc_appointment_app_crfone", | ||
label_lower="edc_appointment_app.crfone", | ||
fld_name="f1", | ||
) | ||
try: | ||
with connection.cursor() as cursor: | ||
cursor.execute(crf_case.sql) | ||
except OperationalError as e: | ||
self.fail(f"OperationalError unexpectedly raised, Got {e}.") | ||
|
||
def test_where_instead_of_fldname_crfcase(self): | ||
"""Assert generates valid SQL or raises""" | ||
# raise for bad fld_name | ||
crf_case = CrfCase( | ||
label="No F1 when F2 is YES", | ||
dbtable="edc_appointment_app_crfone", | ||
label_lower="edc_appointment_app.crfone", | ||
where="bad_fld_name is null and f2='Yes'", | ||
) | ||
|
||
try: | ||
with connection.cursor() as cursor: | ||
cursor.execute(crf_case.sql) | ||
except OperationalError as e: | ||
self.assertIn("bad_fld_name", str(e)) | ||
else: | ||
self.fail("OperationalError not raised for invalid fld_name.") | ||
|
||
# ok | ||
crf_case = CrfCase( | ||
label="No F1 when F2 is YES", | ||
dbtable="edc_appointment_app_crfone", | ||
label_lower="edc_appointment_app.crfone", | ||
where="f1 is null and f2='Yes'", | ||
) | ||
try: | ||
with connection.cursor() as cursor: | ||
cursor.execute(crf_case.sql) | ||
except OperationalError as e: | ||
self.fail(f"OperationalError unexpectedly raised, Got {e}.") | ||
|
||
def test_subquery_crfcase(self): | ||
crf_case = CrfCase( | ||
label="No F1 when F2 is YES", | ||
dbtable="edc_appointment_app_crfone", | ||
label_lower="edc_appointment_app.crfone", | ||
where="f1 is null and f2='Yes'", | ||
) | ||
try: | ||
crf_case.fetchall() | ||
except CrfCaseError as e: | ||
self.fail(f"CrfCaseError unexpectedly raised, Got {e}.") | ||
|
||
def test_subquery_with_recs_crfcase(self): | ||
appointment = Appointment.objects.get(visit_code="1000", visit_code_sequence=0) | ||
subject_visit = SubjectVisit.objects.get(appointment=appointment) | ||
CrfOne.objects.create(subject_visit=subject_visit, f1=None, f2=YES) | ||
crf_case = CrfCase( | ||
label="No F1 when F2 is YES", | ||
dbtable="edc_appointment_app_crfone", | ||
label_lower="edc_appointment_app.crfone", | ||
where="f1 is null and f2='Yes'", | ||
) | ||
try: | ||
rows = crf_case.fetchall() | ||
except CrfCaseError as e: | ||
self.fail(f"CrfCaseError unexpectedly raised, Got {e}.") | ||
self.assertEqual(len(rows), 1) | ||
|
||
def test_requisition_case(self): | ||
appointment = Appointment.objects.get(visit_code="1000", visit_code_sequence=0) | ||
subject_visit = SubjectVisit.objects.get(appointment=appointment) | ||
panel = Panel.objects.create(name=FBC) | ||
subject_requisition = SubjectRequisition.objects.create( | ||
subject_visit=subject_visit, is_drawn=YES, panel=panel | ||
) | ||
# need to pass table names explicitly since app_name for | ||
# BloodResultsFbc CRF is not the same as subject_visit and | ||
# subject_requisition. Normally the defaults are correct. | ||
requisition_case = RequisitionCase( | ||
label="FBC Requisition, no results", | ||
dbtable="edc_qareports_bloodresultsfbc", | ||
label_lower="edc_qareports.bloodresultsfbc", | ||
panel=FBC, | ||
subjectvisit_dbtable="edc_appointment_app_subjectvisit", | ||
subjectrequisition_dbtable="edc_appointment_app_subjectrequisition", | ||
panel_dbtable="edc_appointment_app_panel", | ||
) | ||
try: | ||
rows = requisition_case.fetchall() | ||
except CrfCaseError as e: | ||
self.fail(f"CrfCaseError unexpectedly raised, Got {e}.") | ||
self.assertEqual(len(rows), 1) | ||
|
||
# add the result CRF | ||
BloodResultsFbc.objects.create( | ||
subject_visit=subject_visit, | ||
requisition=subject_requisition, | ||
wbc_value=10.0, | ||
wbc_units=TEN_X_9_PER_LITER, | ||
site_id=10, | ||
) | ||
try: | ||
rows = requisition_case.fetchall() | ||
except CrfCaseError as e: | ||
self.fail(f"CrfCaseError unexpectedly raised, Got {e}.") | ||
self.assertEqual(len(rows), 0) |