Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate sample's DateOfBirth field to AgeDateOfBirthField #74

Merged
merged 21 commits into from
May 18, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Changelog
1.4.0 (unreleased)
------------------

- #74 Migrate sample's DateOfBirth field to AgeDateOfBirthField
- #72 Move Patient ID to identifiers
- #70 Ensure Require MRN setting is honoured
- #69 Fix Patient Workflows and Permissions
Expand Down
74 changes: 45 additions & 29 deletions src/senaite/patient/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
# Some rights reserved, see README and LICENSE.

import re
from bika.lims import deprecated
from datetime import datetime

from bika.lims import api
from bika.lims.utils import tmpID
from dateutil.relativedelta import relativedelta
from senaite.core.api import dtime
from senaite.patient.config import PATIENT_CATALOG
from senaite.patient.permissions import AddPatient
from zope.component import getUtility
Expand Down Expand Up @@ -186,6 +188,7 @@ def update_patient(patient, **values):
patient.reindexObject()


@deprecated("Us senaite.core.api.dtime.to_dt instead")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small typo: Us -> Use

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved with c06ae2b

def to_datetime(date_value, default=None, tzinfo=None):
if isinstance(date_value, datetime):
return date_value
Expand All @@ -202,67 +205,80 @@ def to_datetime(date_value, default=None, tzinfo=None):
return date_value.replace(tzinfo=tzinfo)


def to_ymd(delta):
def to_ymd(val, default=_marker):
"""Returns a representation of a relative delta in ymd format
"""
if not isinstance(delta, relativedelta):
raise TypeError("delta parameter must be a relative_delta")
if not isinstance(val, relativedelta):
if is_ymd(val):
return val
if default is _marker:
raise TypeError("delta parameter must be a relative_delta or ymd")
return default

ymd = list("ymd")
diff = map(str, (delta.years, delta.months, delta.days))
diff = map(str, (val.years, val.months, val.days))
age = filter(lambda it: int(it[0]), zip(diff, ymd))
return " ".join(map("".join, age))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Man, you drive me nuts with constructs like this 🙈

Just to understand:
You are converting the years, months and days to strings. Then you zip them together with ["y", "m", "d"] to filter out empties, e.g. to get 5y2d to finally " ".join(map("".join, age)), which returns 5y2d again?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've seen it in the doctest now, it generates something like 43y 2m 3d.
As proposed further down, please encapsulate it in an own function, e.g. format_ymd to do this.



def is_ymd(ymd):
"""Returns whether the string represents a period in ymd format
"""
valid = map(lambda p: p in ymd, "ymd")
return any(valid)
values = get_years_months_days(ymd, default=None)
return values is not None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the default handling (also below) in favor of this construct:

def is_ymd(ymd):
    try:
        get_years_months_days(ymd)
    except TypeError:
        return False
    return True



def get_birth_date(age_ymd, on_date=None):
"""Returns the birth date given an age in ymd format and the date when age
was recorded or current datetime if None
def get_years_months_days(ymd, default=_marker):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the default handling for simplicity (only used in is_ymd and get_birth_date)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

"""Returns a tuple of (years, month, days)
"""
on_date = to_datetime(on_date, default=datetime.now())

def extract_period(val, period):
num = re.findall(r'(\d{1,2})'+period, val) or [0]
num = re.findall(r'(\d{1,2})'+period, str(val)) or [0]
return api.to_int(num[0], default=0)

# Extract the periods
years = extract_period(age_ymd, "y")
months = extract_period(age_ymd, "m")
days = extract_period(age_ymd, "d")
years = extract_period(ymd, "y")
months = extract_period(ymd, "m")
days = extract_period(ymd, "d")
if not any([years, months, days]):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't we just return (0, 0, 0)?
E.g. for a new born baby with 2 hours.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, with latest changes it always returns (0, 0, 0) unless format or type are not valid

raise AttributeError("No valid ymd: {}".format(age_ymd))
if default is _marker:
raise AttributeError("No valid ymd: {}".format(ymd))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better use a TypeError or ValueError instead of a AttributeError. But probably not needed at all if we just return (0, 0, 0).

return default

return years, months, days


def get_birth_date(age_ymd, on_date=None, default=_marker):
"""Returns the birth date given an age in ymd format and the date when age
was recorded or current datetime if None
"""
try:
ymd = to_ymd(age_ymd)
years, months, days = get_years_months_days(ymd)
except AttributeError:
if default is _marker:
raise AttributeError("No valid ymd: {}".format(age_ymd))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phew, catch an AttributeError (which is actually none) to raise an AttributeError again to (probably) bypass the IndexError that might happen here years, months, days = get_years_months_days(ymd) ...

I would maybe completely skip the handling here and leave it to the get_birth_date to either raise a TypeError or return (0, 0, 0).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I simplified the whole thing and now it raises TypeError or ValueError only if the format is not correct

return default

on_date = dtime.to_dt(on_date) or datetime.now()
dob = on_date - relativedelta(years=years, months=months, days=days)
return dob


def get_age_ymd(birth_date, on_date=None):
"""Returns the age at on_date if not None. Otherwise, current age
"""
delta = get_relative_delta(birth_date, on_date)
return to_ymd(delta)
try:
delta = dtime.get_relative_delta(birth_date, on_date)
return to_ymd(delta)
except (ValueError, TypeError):
return None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do everywhere the default handling except of here you return None? What happened? 😅

Maybe you can skip here the ValueError. At least I see only the explicit TypeError that is raised by to_ymd.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done



@deprecated("Use senaite.core.api.dtime.get_relative_delta instead")
def get_relative_delta(from_date, to_date=None):
"""Returns the relative delta between two dates. If to_date is None,
compares the from_date with now
"""
from_date = to_datetime(from_date)
if not from_date:
raise TypeError("Type not supported: from_date")

to_date = to_date or datetime.now()
to_date = to_datetime(to_date)
if not to_date:
raise TypeError("Type not supported: to_date")

return relativedelta(to_date, from_date)
return dtime.get_relative_delta(from_date, to_date)


def tuplify_identifiers(identifiers):
Expand Down
97 changes: 31 additions & 66 deletions src/senaite/patient/browser/widgets/agedob.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-

from AccessControl import ClassSecurityInfo
from bika.lims import api
from senaite.core.api import dtime
from Products.Archetypes.Registry import registerWidget
from senaite.core.browser.widgets import DateTimeWidget
from senaite.patient import api as patient_api
Expand All @@ -19,113 +19,78 @@ class AgeDoBWidget(DateTimeWidget):
"macro": "senaite_patient_widgets/agedobwidget",
})

def get_age_selected(self, context):
name = self.getName()
attr = "_%s_age_selected" % name
return getattr(context, attr, False)

def set_age_selected(self, context, value):
name = self.getName()
attr = "_%s_age_selected" % name
setattr(context, attr, bool(value))

def get_dob_estimated(self, context):
name = self.getName()
attr = "_%s_dob_estimated" % name
return getattr(context, attr, self.get_age_selected(context))

def set_dob_estimated(self, context, value):
name = self.getName()
attr = "_%s_dob_estimated" % name
setattr(context, attr, bool(value))

def get_current_age(self, dob):
"""Returns a dict with keys "years", "months", "days"
"""
if not api.is_date(dob):
return {}

delta = patient_api.get_relative_delta(dob)
return {
"years": delta.years,
"months": delta.months,
"days": delta.days,
}

def is_age_supported(self, context):
def is_age_supported(self):
"""Returns whether the introduction of age is supported or not
"""
return patient_api.is_age_supported()

def is_years_only(self, dob):
def is_years_only(self):
"""Returns whether months and days are not displayed when the age is
greater than one year
"""
if not patient_api.is_age_in_years():
return False
dob = self.get_current_age(dob)
years = dob.get("years", 0)
return years >= 1
return patient_api.is_age_in_years()

def process_form(self, instance, field, form, empty_marker=None,
emptyReturnsMarker=False, validating=True):
value = form.get(field.getName())

# Not interested in the hidden field, but in the age + dob specific
if isinstance(value, (list, tuple)):
value = value[0] or None

# Allow non-required fields
if not value:
return None, {}

# handle DateTime object when creating partitions
if api.is_date(value):
self.set_age_selected(instance, False)
return value, {}
# We always return a dict suitable for the field
output = dict.fromkeys(["dob", "from_age", "estimated"], False)

# Grab the input for DoB first
dob = value.get("dob", "")
dob = patient_api.to_datetime(dob)
age_selected = value.get("selector") == "age"
if dtime.is_date(value):
# handle date-like objects directly
output["dob"] = dtime.to_dt(value)
return output, {}

# remember what was selected
self.set_age_selected(instance, age_selected)
elif patient_api.is_ymd(value):
# handle age-like inputs directly
output["dob"] = patient_api.get_birth_date(value)
output["from_age"] = True
return output, {}

# Maybe user entered age instead of DoB
if age_selected:
# Validate the age entered
if value.get("selector") == "age":
# Age entered
ymd = map(lambda p: value.get(p), ["years", "months", "days"])
if not any(ymd):
# No values set
return None
return None, {}

# Age in ymd format
ymd = filter(lambda p: p[0], zip(ymd, 'ymd'))
ymd = "".join(map(lambda p: "".join(p), ymd))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, once again this dance. If it is really required, I would somehow encapsulate it in a format_ymd function with a good docstring / comments.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done with e77df12 . Is now using to to_ymd function


# Calculate the DoB
dob = patient_api.get_birth_date(ymd)
output["dob"] = dob
output["from_age"] = True

# Consider DoB as estimated?
orig_dob = value.get("original")
orig_dob = patient_api.to_datetime(orig_dob)
orig_dob = dtime.to_dt(orig_dob)
if not orig_dob:
# First time age is set, assume dob is estimated
self.set_dob_estimated(instance, True)
output["estimated"] = True
else:
# Do not update estimated unless value changed. Maybe the user
# set the DoB at the beginning and now is just viewing the
# Age value in edit mode. We do not want the property
# "estimated" to change if he/she presses the Save button
# without the dob value being changed
if orig_dob.strftime("%y%m%d") != dob.strftime("%y%m%d"):
self.set_dob_estimated(instance, True)
orig_ansi = dtime.to_ansi(orig_dob, show_time=False)
dob_ansi = dtime.to_ansi(dob, show_time=False)
output["estimated"] = orig_ansi != dob_ansi

else:
# User entered date of birth, so is not estimated
self.set_dob_estimated(instance, False)
dob = value.get("dob", "")
output["dob"] = dtime.to_dt(dob)
output["from_age"] = value.get("from_age", False)
output["estimated"] = value.get("estimated", False)

return dob, {}
return output, {}


registerWidget(AgeDoBWidget, title="AgeDoBWidget")
7 changes: 3 additions & 4 deletions src/senaite/patient/content/analysisrequest.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
from bika.lims.interfaces import IAnalysisRequest
from Products.Archetypes.Widget import TextAreaWidget
from Products.CMFCore.permissions import View
from senaite.core.browser.widgets import QuerySelectWidget
from senaite.patient import messageFactory as _
from senaite.patient.api import get_patient_name_entry_mode
from senaite.patient.api import is_age_supported
Expand All @@ -36,9 +35,9 @@
from senaite.patient.catalog import PATIENT_CATALOG
from senaite.patient.config import GENDERS
from senaite.patient.config import SEXES
from senaite.patient.content import ExtDateTimeField
from senaite.patient.content import ExtStringField
from senaite.patient.content import ExtTextField
from senaite.patient.content.fields import AgeDateOfBirthField
from senaite.patient.content.fields import FullnameField
from senaite.patient.content.fields import TemporaryIdentifierField
from senaite.patient.interfaces import ISenaitePatientLayer
Expand Down Expand Up @@ -142,7 +141,7 @@
)
)

DateOfBirthField = ExtDateTimeField(
dob_field = AgeDateOfBirthField(
"DateOfBirth",
required=False,
read_permission=View,
Expand Down Expand Up @@ -210,7 +209,7 @@ def getFields(self):
MedicalRecordNumberField,
PatientFullNameField,
PatientAddressField,
DateOfBirthField,
dob_field,
SexField,
GenderField,
]
Expand Down
Loading