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 all 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
159 changes: 121 additions & 38 deletions src/senaite/patient/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
# 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 six import string_types
from zope.component import getUtility
from zope.component.interfaces import IFactory
from zope.event import notify
Expand All @@ -46,6 +49,10 @@
"condition": "",
}

YMD_REGEX = r'^((?P<y>(\d+))y){0,1}\s*' \
r'((?P<m>(\d+))m){0,1}\s*' \
r'((?P<d>(\d+))d){0,1}\s*'

_marker = object()


Expand Down Expand Up @@ -186,6 +193,7 @@ def update_patient(patient, **values):
patient.reindexObject()


@deprecated("Use senaite.core.api.dtime.to_dt instead")
def to_datetime(date_value, default=None, tzinfo=None):
if isinstance(date_value, datetime):
return date_value
Expand All @@ -202,67 +210,142 @@ def to_datetime(date_value, default=None, tzinfo=None):
return date_value.replace(tzinfo=tzinfo)


def to_ymd(delta):
"""Returns a representation of a relative delta in ymd format
def to_ymd(period, default=_marker):
"""Returns the given period in ymd format

If default is _marker, either a TypeError or ValueError is raised if
the type of the period is not valid or cannot be converted to ymd format

:param period: period to be converted to a ymd format
:type period: str/relativedelta
:param default: fall-back value to return as default
:returns: a string that represents a period in ymd format
:rtype: str
"""
if not isinstance(delta, relativedelta):
raise TypeError("delta parameter must be a relative_delta")
try:
ymd_values = get_years_months_days(period)
except (TypeError, ValueError) as e:
if default is _marker:
raise e
return default

ymd = list("ymd")
diff = map(str, (delta.years, delta.months, delta.days))
age = filter(lambda it: int(it[0]), zip(diff, ymd))
return " ".join(map("".join, age))
# Return in ymd format, with zeros omitted
ymd_values = map(str, ymd_values)
ymd = filter(lambda it: int(it[0]), zip(ymd_values, "ymd"))
return " ".join(map("".join, ymd))


def is_ymd(ymd):
"""Returns whether the string represents a period in ymd format

:param ymd: supposedly ymd string to evaluate
:type ymd: str
:returns: True if a valid period in ymd format
:rtype: bool
"""
valid = map(lambda p: p in ymd, "ymd")
return any(valid)
if not isinstance(ymd, string_types):
return False
try:
get_years_months_days(ymd)
except (TypeError, ValueError):
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
"""
on_date = to_datetime(on_date, default=datetime.now())
def get_years_months_days(period):
"""Returns a tuple of (years, months, days) given a period.

Returns (0, 0, 0) if not possible to extract the years, months and days
from the given period.

def extract_period(val, period):
num = re.findall(r'(\d{1,2})'+period, val) or [0]
return api.to_int(num[0], default=0)
:param period: period of time
:type period: str/relativedelta/tuple/list
:returns: a tuple with the years, months and days
:rtype: tuple
"""
if isinstance(period, relativedelta):
return period.years, period.months, period.days

if isinstance(period, (tuple, list)):
years = api.to_int(period[0], default=0)
months = api.to_int(period[1] if len(period) > 1 else 0, default=0)
days = api.to_int(period[2] if len(period) > 2 else 0, default=0)
return years, months, days

if not isinstance(period, string_types):
raise TypeError("{} is not supported".format(repr(period)))

# to lowercase and remove leading and trailing spaces
raw_ymd = period.lower().strip()

# extract the years, months and days
matches = re.search(YMD_REGEX, raw_ymd)
values = [matches.group(v) for v in "ymd"]

# if all values are None, assume the ymd format was not valid
nones = [value is None for value in values]
if all(nones):
raise ValueError("Not a valid ymd: {}".format(repr(period)))

# replace Nones with zeros and calculate everything with a relativedelta
values = [api.to_int(value, 0) for value in values]
delta = relativedelta(years=values[0], months=values[1], days=values[2])
return get_years_months_days(delta)


def get_birth_date(period, on_date=None, default=_marker):
"""Returns the date when something started given a period in ymd format
and the date when such period was recorded

If on_date is None, uses current date time as the date from which the
birth date is calculated.

When ymd is not a valid period and default value is _marker, a TypeError
or ValueError is raised. Otherwise, it returns the default value converted
to datetime (or None if it cannot be converted)

:param period: period of time
:type period: str/relativedelta
:param on_date: date from which the since date has to be calculated
:type on_date: string/DateTime/datetime/date
:param default: fall-back date-like value to return as default
:returns: a tuple with the years, months and days
:rtype: tuple
"""
# extract the years, months and days from the period
try:
years, months, days = get_years_months_days(period)
except (TypeError, ValueError) as e:
if default is _marker:
raise e
return dtime.to_dt(default)

# Extract the periods
years = extract_period(age_ymd, "y")
months = extract_period(age_ymd, "m")
days = extract_period(age_ymd, "d")
if not any([years, months, days]):
raise AttributeError("No valid ymd: {}".format(age_ymd))
# date when the ymd period was recorded
on_date = dtime.to_dt(on_date)
if not on_date:
on_date = datetime.now()

dob = on_date - relativedelta(years=years, months=months, days=days)
return dob
# calculate the date when everything started
delta = relativedelta(years=years, months=months, days=days)
return on_date - delta


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
100 changes: 32 additions & 68 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,77 @@ 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))
ymd = patient_api.to_ymd(ymd)

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