diff --git a/essmsync/exceptions.py b/essmsync/exceptions.py index 12eedc47783289fd55fee5038c833e0c8b2a4532..7828395e6019c2ee79029e2895f6f7c8cad82424 100644 --- a/essmsync/exceptions.py +++ b/essmsync/exceptions.py @@ -69,3 +69,10 @@ class ComparisonError(RuntimeError): Fatal condition during comparison """ + + +class ConversionError(RuntimeError): + """ + Fatal data conversion error + + """ diff --git a/essmsync/utils.py b/essmsync/utils.py index 7d369058c64ec18c252bf21e341970d08918ad52..c465cd92570d8d1d4334b532180f6c5ff7184308 100644 --- a/essmsync/utils.py +++ b/essmsync/utils.py @@ -6,8 +6,10 @@ import requests import logging import json import time +import re +from dateutil import parser, tz, relativedelta -from .exceptions import APIError +from .exceptions import APIError, ConversionError LOG = logging.getLogger(__name__) @@ -59,3 +61,200 @@ def api_via_method(method_func, url, exception=APIError, retries=2, **kwargs): except requests.exceptions.RequestException as e: LOG.error(f'Request {url} Failed: {e}') raise exception() + + +def booker_parse_datetime(s): + """ + Takes a ISO 8601 "zulu" string as returned by Booker and returns a + datetime.datetime with UTC timezone + + >>> booker_parse_datetime('2001-02-03T04:05:06Z') + datetime.datetime(2001, 2, 3, 4, 5, 6, tzinfo=tzutc()) + >>> booker_parse_datetime('2007-08-09T10:11:12Z') + datetime.datetime(2007, 8, 9, 10, 11, 12, tzinfo=tzutc()) + >>> booker_parse_datetime('invalid-format') + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: Invalid Booker datetime + >>> booker_parse_datetime('2013-14-15T16:17:18Z') + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: Unable to parse Booker datetime + + """ + if re.fullmatch('[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z', s) is None: + LOG.error(f"Invalid formatted booker datetime '{s}'") + raise ConversionError("Invalid Booker datetime") + try: + return parser.isoparse(s) + except (ValueError, OverflowError) as e: + LOG.error(f"Unable to parse Booker datetime '{s}': {e}") + raise ConversionError("Unable to parse Booker datetime") + + +def booker_format_datetime(dt): + """ + Takes a datetime.datetime object which must have a timezone and renders it + as an ISO 8601 "zulu" string (i.e. 2001-02-03T04:05:06Z) + + >>> from datetime import datetime + >>> from dateutil import tz + >>> booker_format_datetime(datetime(2001, 2, 3, 4, 5, 6, tzinfo=tz.UTC)) + '2001-02-03T04:05:06Z' + >>> booker_format_datetime(datetime(2001, 2, 3, 4, 5, 6, tzinfo=tz.gettz('GB'))) + '2001-02-03T04:05:06Z' + >>> booker_format_datetime(datetime(2007, 8, 9, 10, 11, 12, tzinfo=tz.UTC)) + '2007-08-09T10:11:12Z' + >>> booker_format_datetime(datetime(2007, 8, 9, 10, 11, 12, tzinfo=tz.gettz('GB'))) + '2007-08-09T09:11:12Z' + >>> booker_format_datetime(datetime(2007, 8, 9, 10, 11, 12)) + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: Booker datetime missing timezone + + """ + if dt.tzinfo is None: + LOG.error(f"Booker datetime missing timezone '{dt}'") + raise ConversionError("Booker datetime missing timezone") + if dt.tzinfo is not tz.tzutc(): + dt = dt.astimezone(tz.tzutc()) + return dt.strftime('%Y-%m-%dT%H:%M:%S') + 'Z' + + +def termtime_parse_datetime(s): + """ + Takes a datetime string as returned by TermTime for scheduled activities in + local UK time (e.g. 2020-21-22 09:10:11) and returns a datetime.datetime with + UTC timezone. + + >>> from dateutil import tz + >>> termtime_parse_datetime('2001-02-03 04:05:06').astimezone(tz.UTC) + datetime.datetime(2001, 2, 3, 4, 5, 6, tzinfo=tzutc()) + >>> termtime_parse_datetime('2007-08-09 10:11:12').astimezone(tz.UTC) + datetime.datetime(2007, 8, 9, 9, 11, 12, tzinfo=tzutc()) + >>> termtime_parse_datetime('invalid-format') + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: Invalid TermTime datetime + >>> termtime_parse_datetime('2013-14-15 16:17:18') + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: Unable to parse TermTime datetime + + """ + if re.fullmatch('[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}', s) is None: + LOG.error(f"Invalid formatted TermTime datetime '{s}'") + raise ConversionError("Invalid TermTime datetime") + try: + return parser.parse(s).replace(tzinfo=tz.gettz('GB')) + except (ValueError, OverflowError) as e: + LOG.error(f"Unable to parse TermTime datetime '{s}': {e}") + raise ConversionError("Unable to parse TermTime datetime") + + +def termtime_format_datetime(dt): + """ + Takes a datetime.datetime object which must have a timezone and returns a + string as used by TermTime scheduled events API, which formats the datetime + in local UK time (yyyy-mm-dd hh:mm:ss) + + + >>> from datetime import datetime + >>> from dateutil import tz + >>> termtime_format_datetime(datetime(2001, 2, 3, 4, 5, 6, tzinfo=tz.gettz('GB'))) + '2001-02-03 04:05:06' + >>> termtime_format_datetime(datetime(2001, 2, 3, 4, 5, 6, tzinfo=tz.UTC)) + '2001-02-03 04:05:06' + >>> termtime_format_datetime(datetime(2007, 8, 9, 10, 11, 12, tzinfo=tz.gettz('GB'))) + '2007-08-09 10:11:12' + >>> termtime_format_datetime(datetime(2007, 8, 9, 10, 11, 12, tzinfo=tz.UTC)) + '2007-08-09 11:11:12' + >>> termtime_format_datetime(datetime(2007, 8, 9, 10, 11, 12)) + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: TermTime datetime missing timezone + + """ + if dt.tzinfo is None: + LOG.error(f"TermTime datetime missing timezone '{dt}'") + raise ConversionError("TermTime datetime missing timezone") + if dt.tzinfo is not tz.gettz('GB'): + dt = dt.astimezone(tz.gettz('GB')) + return dt.strftime('%Y-%m-%d %H:%M:%S') + + +def termtime_booking_format_datetime(start_dt, end_dt): + """ + Takes a start and end datetime.datetime objects which must have a timezone + and be on the same day, returns a dict containing time (hh:mm), du (hh:mm), + day (1-7, 1=Mon), pat (ISO week, yyyy:Www) in local UK time, as used by + TermTime room bookings API + + >>> from datetime import datetime + >>> from dateutil import tz + >>> termtime_booking_format_datetime( + ... datetime(2020, 1, 2, 3, 4, 5, tzinfo=tz.gettz('GB')), + ... datetime(2020, 1, 2, 5, 34, 56, tzinfo=tz.gettz('GB')) + ... ) == {'time': '03:04', 'du': '02:30', 'day': 4, 'pat': '2020:W01'} + True + >>> termtime_booking_format_datetime( + ... datetime(2020, 1, 2, 3, 4, 5, tzinfo=tz.UTC), + ... datetime(2020, 1, 2, 5, 34, 56, tzinfo=tz.UTC) + ... ) == {'time': '03:04', 'du': '02:30', 'day': 4, 'pat': '2020:W01'} + True + >>> termtime_booking_format_datetime( + ... datetime(2020, 8, 9, 10, 11, 12, tzinfo=tz.gettz('GB')), + ... datetime(2020, 8, 9, 10, 56, 56, tzinfo=tz.gettz('GB')) + ... ) == {'time': '10:11', 'du': '00:45', 'day': 7, 'pat': '2020:W32'} + True + >>> termtime_booking_format_datetime( + ... datetime(2020, 8, 9, 10, 11, 12, tzinfo=tz.UTC), + ... datetime(2020, 8, 9, 10, 56, 56, tzinfo=tz.UTC) + ... ) == {'time': '11:11', 'du': '00:45', 'day': 7, 'pat': '2020:W32'} + True + >>> termtime_booking_format_datetime( + ... datetime(2020, 8, 9, 10, 11, 12, tzinfo=tz.UTC), + ... datetime(2020, 8, 10, 10, 56, 56, tzinfo=tz.UTC) + ... ) + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: TermTime dates not on same day + >>> termtime_booking_format_datetime( + ... datetime(2020, 8, 9, 10, 11, 12), + ... datetime(2020, 8, 9, 10, 56, 56, tzinfo=tz.UTC) + ... ) + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: TermTime start datetime missing timezone + >>> termtime_booking_format_datetime( + ... datetime(2020, 8, 9, 10, 11, 12, tzinfo=tz.UTC), + ... datetime(2020, 8, 9, 10, 56, 56) + ... ) + Traceback (most recent call last): + ... + essmsync.exceptions.ConversionError: TermTime end datetime missing timezone + + """ + if start_dt.tzinfo is None: + LOG.error(f"TermTime start date missing timezone '{start_dt}'") + raise ConversionError("TermTime start datetime missing timezone") + if end_dt.tzinfo is None: + LOG.error(f"TermTime end date missing timezone '{end_dt}'") + raise ConversionError("TermTime end datetime missing timezone") + if start_dt.date() != end_dt.date(): + LOG.error(f"TermTime booking dates not on same day '{start_dt}' to '{end_dt}'") + raise ConversionError("TermTime dates not on same day") + + diff = relativedelta.relativedelta(end_dt, start_dt) + (year, week, dow) = start_dt.isocalendar() + + if start_dt.tzinfo is not tz.gettz('GB'): + start_dt = start_dt.astimezone(tz.gettz('GB')) + + r = { + 'time': start_dt.strftime('%H:%M'), + 'du': f"{diff.hours:02}:{diff.minutes:02}", + 'day': dow, + 'pat': f"{year}:W{week:02}", + } + return r diff --git a/requirements.txt b/requirements.txt index cff51e35d7971f2f46b1175b636e84f0959e9d64..13dc0ab0113a4aff1001b64d160e45e86ab6c3cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ docopt PyYAML dpath requests +python-dateutil