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

Added get_relative_delta and get_tzinfo in datetime API #2310

Merged
merged 11 commits into from
May 17, 2023
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Changelog
2.5.0 (unreleased)
------------------

- #2310 Added `get_relative_delta` and `get_tzinfo` in datetime API
- #2311 Properly process and validate field values from sample header on submit
- #2307 Rely on fields when validating submitted values on sample creation
- #2305 Add support for dates in ANSI X3.30 and ANSI X3.43.3 formats
Expand Down
64 changes: 63 additions & 1 deletion src/senaite/core/api/dtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import time
from datetime import date
from datetime import datetime
from dateutil.relativedelta import relativedelta
from string import Template

import six
Expand Down Expand Up @@ -207,7 +208,7 @@ def to_ansi(dt, show_time=True):


def get_timezone(dt, default="Etc/GMT"):
"""Get a valid pytz timezone of the datetime object
"""Get a valid pytz timezone name of the datetime object

:param dt: date object
:returns: timezone as string, e.g. Etc/GMT or CET
Expand Down Expand Up @@ -237,6 +238,29 @@ def get_timezone(dt, default="Etc/GMT"):
return tz


def get_tzinfo(dt_tz, default=pytz.UTC):
"""Returns the valid pytz tinfo from the date or timezone name

Returns the default timezone info if date does not have a valid timezone
set or is TZ-naive

:param dt: timezone name or date object to extract the tzinfo
:type dt: str/date/datetime/DateTime
:param: default: timezone name or pytz tzinfo object
:returns: pytz tzinfo object, e.g. `<UTC>, <StaticTzInfo 'Etc/GMT+2'>
:rtype: UTC/BaseTzInfo/StaticTzInfo/DstTzInfo
"""
if is_str(default):
default = pytz.timezone(default)
try:
if is_str(dt_tz):
return pytz.timezone(dt_tz)
tz = get_timezone(dt_tz, default=default.zone)
return pytz.timezone(tz)
except pytz.UnknownTimeZoneError:
return default


def is_valid_timezone(timezone):
"""Checks if the timezone is a valid pytz/Olson name

Expand Down Expand Up @@ -436,3 +460,41 @@ def to_localized_time(dt, long_format=None, time_only=None,
formatstring = "[INTERNAL ERROR]"
time_str = date_to_string(dt, formatstring, default=default)
return time_str


def get_relative_delta(dt1, dt2=None):
"""Calculates the relative delta between two dates or datetimes

If `dt2` is None, the current datetime is used.

:param dt1: the first date/time to compare
:type dt1: string/date/datetime/DateTime
:param dt2: the second date/time to compare
:type dt2: string/date/datetime/DateTime
:returns: interval of time (e.g. `relativedelta(hours=+3)`)
:rtype: dateutil.relativedelta
"""
if not dt2:
dt2 = datetime.now()

dt1 = to_dt(dt1)
dt2 = to_dt(dt2)
if not all([dt1, dt2]):
raise ValueError("No valid date or dates")

naives = [is_timezone_naive(dt) for dt in [dt1, dt2]]
if all(naives):
# Both naive, no need to do anything special
return relativedelta(dt2, dt1)

elif is_timezone_naive(dt1):
# From date is naive, assume same TZ as the to date
tzinfo = get_tzinfo(dt2)
dt1 = dt1.replace(tzinfo=tzinfo)

elif is_timezone_naive(dt2):
# To date is naive, assume same TZ as the from date
tzinfo = get_tzinfo(dt1)
dt2 = dt2.replace(tzinfo=tzinfo)

return relativedelta(dt2, dt1)
158 changes: 158 additions & 0 deletions src/senaite/core/tests/doctests/API_datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,74 @@ Get the timezone from `datetime.date` objects:
'Etc/GMT'


Get the timezone info
.....................

Get the timezone info from TZ name:

>>> dtime.get_tzinfo("Etc/GMT")
<StaticTzInfo 'Etc/GMT'>

>>> dtime.get_tzinfo("Pacific/Fiji")
<DstTzInfo 'Pacific/Fiji' LMT+11:56:00 STD>

>>> dtime.get_tzinfo("UTC")
<UTC>

Get the timezone info from `DateTime` objects:

>>> dtime.get_tzinfo(DateTime("2022-02-25"))
<StaticTzInfo 'Etc/GMT'>

>>> dtime.get_tzinfo(DateTime("2022-02-25 12:00 GMT+2"))
<StaticTzInfo 'Etc/GMT-2'>

>>> dtime.get_tzinfo(DateTime("2022-02-25 12:00 GMT-2"))
<StaticTzInfo 'Etc/GMT+2'>

Get the timezone info from `datetime.datetime` objects:

>>> DATE = "2021-12-24 12:00"
>>> dt = datetime.strptime(DATE, DATEFORMAT)
>>> dtime.get_tzinfo(dt)
<UTC>

>>> dtime.get_tzinfo(dtime.to_zone(dt, "Europe/Berlin"))
<DstTzInfo 'CET' CET+1:00:00 STD>

Get the timezone info from `datetime.date` objects:

>>> dtime.get_tzinfo(dt.date)
<UTC>

Getting the timezone info from a naive date returns default timezone info:

>>> dt_naive = dt.replace(tzinfo=None)
>>> dtime.get_tzinfo(dt_naive)
<UTC>

>>> dtime.get_tzinfo(dt_naive, default="Pacific/Fiji")
<DstTzInfo 'Pacific/Fiji' LMT+11:56:00 STD>

We can use a timezone info as the default parameter as well:

>>> dtime.get_tzinfo(dt_naive, default=dtime.pytz.UTC)
<UTC>

Default can also be a timezone name:

>>> dtime.get_tzinfo(dt_naive, default="America/Port_of_Spain")
<DstTzInfo 'America/Port_of_Spain' LMT-1 day, 19:36:00 STD>

And an error is rised if default is not a valid timezone, even if the date
passed-in is valid:

>>> dtime.get_tzinfo(dt_naive, default="Atlantida")
Traceback (most recent call last):
...
UnknownTimeZoneError: 'Atlantida'


Check if timezone is valid
..........................

Expand Down Expand Up @@ -619,3 +687,93 @@ Still, invalid dates return None:
>>> dt = "20030230123408"
>>> dtime.to_ansi(dt) is None
True


Relative delta between two dates
................................

We can extract the relative delta between two dates:

>>> dt1 = dtime.ansi_to_dt("20230515104405")
>>> dt2 = dtime.ansi_to_dt("20230515114405")
>>> dtime.get_relative_delta(dt1, dt2)
relativedelta(hours=+1)

We can even compare two dates from two different timezones:

>>> dt1_cet = dtime.to_zone(dt1, "CET")
>>> dt2_utc = dtime.to_zone(dt2, "UTC")
>>> dtime.get_relative_delta(dt1_cet, dt2_utc)
relativedelta(hours=+3)

>>> dt1_cet = dtime.to_zone(dt1, "CET")
>>> dt2_pcf = dtime.to_zone(dt2, "Pacific/Fiji")
>>> dtime.get_relative_delta(dt1_cet, dt2_pcf)
relativedelta(hours=-9)

If we compare a naive timezone, system uses the timezone of the other date:

>>> dt1_cet = dtime.to_zone(dt1, "CET")
>>> dt2_naive = dt2.replace(tzinfo=None)
>>> dtime.get_relative_delta(dt1_cet, dt2_naive)
relativedelta(hours=+3)

It also works when both are timezone naive:

>>> dt1_naive = dt1.replace(tzinfo=None)
>>> dt2_naive = dt2.replace(tzinfo=None)
>>> dtime.get_relative_delta(dt1_naive, dt2_naive)
relativedelta(hours=+1)

If we don't specify `dt2`, system simply uses current datetime:

>>> rel_now = dtime.get_relative_delta(dt1, datetime.now())
>>> rel_wo = dtime.get_relative_delta(dt1)
>>> rel_now = (rel_now.years, rel_now.months, rel_now.days, rel_now.hours)
>>> rel_wo = (rel_wo.years, rel_wo.months, rel_wo.days, rel_wo.hours)
>>> rel_now == rel_wo
True

We can even compare min and max dates:

>>> dt1 = dtime.datetime.min
>>> dt2 = dtime.datetime.max
>>> dtime.get_relative_delta(dtime.datetime.min, dtime.datetime.max)
relativedelta(years=+9998, months=+11, days=+30, hours=+23, minutes=+59, seconds=+59, microseconds=+999999)

We can even call the function with types that are not datetime, but can be
converted to datetime:

>>> dtime.get_relative_delta("19891201131405", "20230515114400")
relativedelta(years=+33, months=+5, days=+13, hours=+22, minutes=+29, seconds=+55)

But raises a `ValueError` if non-valid dates are used:

>>> dtime.get_relative_delta("17891301132505")
Traceback (most recent call last):
...
ValueError: No valid date or dates

Even if the from date is correct, but not the to date:

>>> dtime.get_relative_delta("19891201131405", "20230535114400")
Traceback (most recent call last):
...
ValueError: No valid date or dates

We can also compare two datetimes, being the "from" earlier than "to":

>>> dtime.get_relative_delta("20230515114400", "19891201131405")
relativedelta(years=-33, months=-5, days=-13, hours=-22, minutes=-29, seconds=-55)

Or compare two dates that are exactly the same:

>>> dtime.get_relative_delta("20230515114400", "20230515114400")
relativedelta()

We can compare dates without time as well:

>>> from_date = dtime.date(2023, 5, 6)
>>> to_date = dtime.date(2023, 5, 7)
>>> dtime.get_relative_delta(from_date, to_date)
relativedelta(days=+1)