diff --git a/CHANGES.rst b/CHANGES.rst index 0ab2a0e389..f8c0e89e7a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 diff --git a/src/senaite/core/api/dtime.py b/src/senaite/core/api/dtime.py index 01efc6a570..02584a4105 100644 --- a/src/senaite/core/api/dtime.py +++ b/src/senaite/core/api/dtime.py @@ -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 @@ -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 @@ -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. `, + :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 @@ -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) diff --git a/src/senaite/core/tests/doctests/API_datetime.rst b/src/senaite/core/tests/doctests/API_datetime.rst index f4e4d92e34..a07ecd3128 100644 --- a/src/senaite/core/tests/doctests/API_datetime.rst +++ b/src/senaite/core/tests/doctests/API_datetime.rst @@ -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") + + + >>> dtime.get_tzinfo("Pacific/Fiji") + + + >>> dtime.get_tzinfo("UTC") + + +Get the timezone info from `DateTime` objects: + + >>> dtime.get_tzinfo(DateTime("2022-02-25")) + + + >>> dtime.get_tzinfo(DateTime("2022-02-25 12:00 GMT+2")) + + + >>> dtime.get_tzinfo(DateTime("2022-02-25 12:00 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) + + + >>> dtime.get_tzinfo(dtime.to_zone(dt, "Europe/Berlin")) + + +Get the timezone info from `datetime.date` objects: + + >>> dtime.get_tzinfo(dt.date) + + +Getting the timezone info from a naive date returns default timezone info: + + >>> dt_naive = dt.replace(tzinfo=None) + >>> dtime.get_tzinfo(dt_naive) + + + >>> dtime.get_tzinfo(dt_naive, default="Pacific/Fiji") + + +We can use a timezone info as the default parameter as well: + + >>> dtime.get_tzinfo(dt_naive, default=dtime.pytz.UTC) + + +Default can also be a timezone name: + + >>> dtime.get_tzinfo(dt_naive, default="America/Port_of_Spain") + + +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 .......................... @@ -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)