Skip to content

Commit

Permalink
Merge branch 'release/0.1.14' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
erikvw committed Aug 24, 2024
2 parents ff1564a + 6c6a582 commit ed64ca2
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 0 deletions.
1 change: 1 addition & 0 deletions edc_qareports/model_mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .note_model_mixin import NoteModelMixin
from .on_study_missing_values_model_mixin import OnStudyMissingValuesModelMixin
from .qa_report_model_mixin import QaReportModelMixin
from .qa_reports_permissions import qa_reports_permissions
24 changes: 24 additions & 0 deletions edc_qareports/model_mixins/on_study_missing_values_model_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.db import models


class OnStudyMissingValuesModelMixin(models.Model):
original_id = models.UUIDField(null=True)

label_lower = models.CharField(max_length=150, null=True)

subject_visit_id = models.UUIDField(null=True)

report_datetime = models.DateTimeField(null=True)

label = models.CharField(max_length=50, null=True)

visit_code = models.CharField(max_length=25, null=True)

visit_code_sequence = models.IntegerField(null=True)

schedule_name = models.CharField(max_length=25, null=True)

modified = models.DateTimeField(null=True)

class Meta:
abstract = True
3 changes: 3 additions & 0 deletions edc_qareports/modeladmin_mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from .list_filters import NoteStatusListFilter
from .note_modeladmin_mixin import NoteModelAdminMixin
from .on_study_missing_values_modeladmin_mixin import (
OnStudyMissingValuesModelAdminMixin,
)
from .qa_report_modeladmin_mixin import QaReportModelAdminMixin
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from django.apps import apps as django_apps
from django.contrib import admin
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext as _
from edc_model_admin.dashboard import ModelAdminDashboardMixin
from edc_model_admin.mixins import TemplatesModelAdminMixin
from edc_sites.admin import SiteModelAdminMixin
from edc_visit_schedule.admin import ScheduleStatusListFilter

from .qa_report_modeladmin_mixin import QaReportModelAdminMixin


class OnStudyMissingValuesModelAdminMixin(
QaReportModelAdminMixin,
SiteModelAdminMixin,
ModelAdminDashboardMixin,
TemplatesModelAdminMixin,
):
include_note_column: bool = True
project_reports_admin: str = "meta_reports_admin"
project_subject_admin: str = "meta_subject_admin"

ordering = ["site", "subject_identifier"]

list_display = [
"dashboard",
"subject_identifier",
"site",
"label",
"crf",
"visit",
"report_date",
"created",
]

list_filter = [
ScheduleStatusListFilter,
"label",
"visit_code",
"report_datetime",
]

search_fields = ["subject_identifier", "label"]

def dashboard(self, obj=None, label=None) -> str:
url = self.get_subject_dashboard_url(obj=obj)
if not url:
url = reverse(
f"{self.project_subject_admin}:{obj.label_lower.replace('.', '_')}_change",
args=(obj.original_id,),
)
url = (
f"{url}?next={self.project_reports_admin}:"
f"{self.model._meta.label_lower.replace('.', '_')}_changelist"
)
context = dict(title=_("Go to CRF"), url=url, label=label)
return render_to_string("dashboard_button.html", context=context)

@admin.display(description="CRF", ordering="label_lower")
def crf(self, obj=None) -> str:
model_cls = django_apps.get_model(obj.label_lower)
return model_cls._meta.verbose_name

@admin.display(description="Visit", ordering="visit_code")
def visit(self, obj=None) -> str:
return f"{obj.visit_code}.{obj.visit_code_sequence}"

@admin.display(description="Report date", ordering="report_datetime")
def report_date(self, obj) -> str | None:
if obj.report_datetime:
return obj.report_datetime.date()
return None
3 changes: 3 additions & 0 deletions edc_qareports/sql_generator/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .generate_subquery_for_missing_values import generate_subquery_for_missing_values
from .select_from import SelectFrom
from .sql_view_generator import SqlViewGenerator
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from .select_from import SelectFrom


def generate_subquery_for_missing_values(
cases: list[dict[str:str, str:str, str:str]],
as_list: bool | None = False,
) -> str | list:
"""Returns an SQL select statement as a union of the select
statements of each case.
args:
cases = [{
"label_lower": "my_app.hivhistory",
"dbtable": "my_app_hivhistory",
"field": "hiv_init_date",
"label": "missing HIV initiation date",
"list_tables": [(list_field, list_dbtable, alias), ...],
}, ...]
Note: `list_field` is the CRF id field, for example:
left join <list_dbtable> as <alias> on crf.<list_field>=<alias>.id
"""
select_from_list = []
for case in cases:
select_from = SelectFrom(**case)
select_from_list.append(select_from.sql)
if as_list:
return select_from_list
return " UNION ".join(select_from_list)
39 changes: 39 additions & 0 deletions edc_qareports/sql_generator/select_from.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from dataclasses import dataclass, field


@dataclass(kw_only=True)
class SelectFrom:
label: str = None
label_lower: str = None
dbtable: str = None
fld_name: str | None = None
where: str | None = None
list_tables: list[tuple[str, str, str]] | None = field(default_factory=list)
template: str = field(
init=False,
default="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 meta_subject_subjectvisit 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", # noqa
)
sql: str | None = field(init=False, default=None)

def __post_init__(self):
if self.where is None:
self.where = f"crf.{self.fld_name} is null"
self.sql = self.template.format(
label=self.label,
label_lower=self.label_lower,
dbtable=self.dbtable,
where=self.where,
left_joins=self.left_joins,
)
self.sql = self.sql.replace(";", "")

@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)
73 changes: 73 additions & 0 deletions edc_qareports/sql_generator/sql_view_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from dataclasses import dataclass, field

import sqlglot


@dataclass(kw_only=True)
class SqlViewGenerator:
"""A class to generate SQL view statements given a select
statement.
Generated SQL is compatible with mysql, pgsql and sqlite3.
The given `select_statment` is not validated.
For use with view definitions.
"""

report_model: str = None
ordering: list[str] | None = field(default_factory=list)
order_by: str | None = field(init=False, default=None)
with_stmt: str | None = field(default="")
footer: str = field(init=False, default=None)
template: str | None = field(
default="{with_stmt} {select_stmt} from ({subquery}) as A ORDER BY {order_by};"
)

def __post_init__(self):
ordering = [f"{fld[1:]} desc" if fld.startswith("~") else fld for fld in self.ordering]
self.order_by = ", ".join(ordering) or "subject_identifier"
self.footer = f") as A ORDER BY {self.order_by}"

@staticmethod
def transpile(sql: str, read: str | None = None, write: str = None) -> str:
read = read or "mysql"
sql = sql.replace(";", "")
return sqlglot.transpile(sql, read=read, write=write)[0]

def as_mysql(self, subquery: str, read: str | None = None) -> str:
select_stmt = f"select *, uuid() as id, now() as `created`, '{self.report_model}' as `report_model`" # noqa
subquery = self.transpile(subquery, read=read, write="mysql")
return self.template.format(
with_stmt=self.with_stmt,
select_stmt=select_stmt,
subquery=subquery,
order_by=self.order_by,
)

def as_postgres(self, subquery: str, read: str | None = None) -> str:
select_stmt = f"select *, get_random_uuid() as id, now() as created, '{self.report_model}' as report_model" # noqa
subquery = self.transpile(subquery, read=read, write="postgres")
return self.template.format(
with_stmt=self.with_stmt,
select_stmt=select_stmt,
subquery=subquery,
order_by=self.order_by,
)

def as_sqlite(self, subquery: str, read: str | None = None) -> str:
"""For UUID support in sqlite, install sqlite/uuid.
See https://github.com/nalgeon/sqlpkg-cli?tab=readme-ov-file
and https://sqlpkg.org/?q=uuid
$ curl -sS https://webi.sh/sqlpkg | sh
$ sqlpkg install sqlite/uuid
"""
select_stmt = f"select *, uuid() as id, datetime() as created, '{self.report_model}' as report_model" # noqa
subquery = self.transpile(subquery, read=read, write="sqlite")
return self.template.format(
with_stmt=self.with_stmt,
select_stmt=select_stmt,
subquery=subquery,
order_by=self.order_by,
)
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ classifiers=
Environment :: Web Environment
Framework :: Django
Framework :: Django :: 4.2
Framework :: Django :: 5.1
Intended Audience :: Developers
Intended Audience :: Science/Research
Operating System :: OS Independent
Expand All @@ -25,6 +26,8 @@ python_requires = >=3.12
zip_safe = False
include_package_data = True
packages = find:
install_requires =
sqlglot[rs]

[options.packages.find]
exclude =
Expand Down

0 comments on commit ed64ca2

Please sign in to comment.