Passed
Push — 2.x ( 7be1e3...9538a4 )
by Jordi
07:43
created

senaite.core.api.dtime.is_valid_timezone()   A

Complexity

Conditions 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 11
rs 10
c 0
b 0
f 0
cc 2
nop 1
1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2024 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import os
22
import re
23
import time
24
from datetime import date
25
from datetime import datetime
26
from datetime import timedelta
27
from dateutil.relativedelta import relativedelta
28
from string import Template
29
30
import six
31
32
import pytz
33
from bika.lims import logger
34
from bika.lims.api import APIError
35
from bika.lims.api import get_tool
36
from bika.lims.api import to_int
37
from DateTime import DateTime
38
from DateTime.DateTime import DateError
39
from DateTime.DateTime import DateTimeError
40
from DateTime.DateTime import SyntaxError
41
from DateTime.DateTime import TimeError
42
from zope.i18n import translate
43
44
45
RX_GMT = r"^(\bGMT\b|)([+-]?)(\d{1,2})"
46
47
_marker = object()
48
49
50
def is_str(obj):
51
    """Check if the given object is a string
52
53
    :param obj: arbitrary object
54
    :returns: True when the object is a string
55
    """
56
    return isinstance(obj, six.string_types)
57
58
59
def is_d(dt):
60
    """Check if the date is a Python `date` object
61
62
    :param dt: date to check
63
    :returns: True when the date is a Python `date`
64
    """
65
    return type(dt) is date
66
67
68
def is_dt(dt):
69
    """Check if the date is a Python `datetime` object
70
71
    :param dt: date to check
72
    :returns: True when the date is a Python `datetime`
73
    """
74
    return type(dt) is datetime
75
76
77
def is_DT(dt):
78
    """Check if the date is a Zope `DateTime` object
79
80
    :param dt: object to check
81
    :returns: True when the object is a Zope `DateTime`
82
    """
83
    return type(dt) is DateTime
84
85
86
def is_date(dt):
87
    """Check if the date is a datetime or DateTime object
88
89
    :param dt: date to check
90
    :returns: True when the object is either a datetime or DateTime
91
    """
92
    if is_str(dt):
93
        DT = to_DT(dt)
94
        return is_date(DT)
95
    if is_d(dt):
96
        return True
97
    if is_dt(dt):
98
        return True
99
    if is_DT(dt):
100
        return True
101
    return False
102
103
104
def is_timezone_naive(dt):
105
    """Check if the date is timezone naive
106
107
    :param dt: date to check
108
    :returns: True when the date has no timezone
109
    """
110
    if is_d(dt):
111
        return True
112
    elif is_DT(dt):
113
        return dt.timezoneNaive()
114
    elif is_dt(dt):
115
        return dt.tzinfo is None
116
    elif is_str(dt):
117
        DT = to_DT(dt)
118
        return is_timezone_naive(DT)
119
    raise APIError("Expected a date type, got '%r'" % type(dt))
120
121
122
def is_timezone_aware(dt):
123
    """Check if the date is timezone aware
124
125
    :param dt: date to check
126
    :returns: True when the date has a timezone
127
    """
128
    return not is_timezone_naive(dt)
129
130
131
def to_DT(dt):
132
    """Convert to DateTime
133
134
    :param dt: DateTime/datetime/date
135
    :returns: DateTime object
136
    """
137
    INTERNATIONAL_FMT = re.compile(
138
        r"^\s*(3[01]|[12][0-9]|0?[1-9])\.(1[012]|0?[1-9])\.(\d{2,4})\s*"
139
    )
140
    if is_DT(dt):
141
        return dt
142
    elif is_str(dt):
143
        kwargs = {}
144
        if re.match(INTERNATIONAL_FMT, dt):
145
            # This will fail silently and you get a wrong date:
146
            # dt = DateTime("02.07.2010") # Parses like US date 02/07/2010
147
            # https://github.com/zopefoundation/DateTime/blob/master/src/DateTime/DateTime.py#L641-L645
148
            kwargs["datefmt"] = "international"
149
        try:
150
            return DateTime(dt, **kwargs)
151
        except (DateError, DateTimeError, TimeError):
152
            try:
153
                dt = ansi_to_dt(dt)
154
                return to_DT(dt)
155
            except ValueError:
156
                return None
157
        except (SyntaxError, IndexError):
158
            return None
159
    elif is_dt(dt):
160
        try:
161
            # We do this because isoformat() comes with the +/- utc offset at
162
            # the end, so it becomes easier than having to rely on tzinfo stuff
163
            return DateTime(dt.isoformat())
164
        except DateTimeError:
165
            return DateTime(dt)
166
    elif is_d(dt):
167
        dt = datetime(dt.year, dt.month, dt.day)
168
        return DateTime(dt.isoformat())
169
    else:
170
        return None
171
172
173
def to_dt(dt):
174
    """Convert to datetime
175
176
    :param dt: DateTime/datetime/date
177
    :returns: datetime object
178
    """
179
    if is_DT(dt):
180
        # get a valid pytz timezone
181
        tz = get_timezone(dt)
182
        dt = dt.asdatetime()
183
        if is_valid_timezone(tz):
184
            dt = to_zone(dt, tz)
185
        return dt
186
    elif is_str(dt):
187
        DT = to_DT(dt)
188
        return to_dt(DT)
189
    elif is_dt(dt):
190
        return dt
191
    elif is_d(dt):
192
        return datetime(dt.year, dt.month, dt.day)
193
    else:
194
        return None
195
196
197
def now():
198
    """Returns a timezone-aware datetime representing current date and time
199
    as defined in Zope's TZ environment variable or, if not set, from OS
200
201
    :returns: timezone-aware datetime object
202
    """
203
    return to_dt(DateTime())
204
205
206
def ansi_to_dt(dt):
207
    """The YYYYMMDD format is defined by ANSI X3.30. Therefore, 2 December 1,
208
    1989 would be represented as 19891201. When times are transmitted, they
209
    shall be represented as HHMMSS, and shall be linked to dates as specified
210
    by ANSI X3.43.3 Date and time together shall be specified as up to a
211
    14-character string: YYYYMMDD[HHMMSS]
212
    :param str:
213
    :return: datetime object
214
    """
215
    if not is_str(dt):
216
        raise TypeError("Type is not supported")
217
    if len(dt) == 8:
218
        date_format = "%Y%m%d"
219
    elif len(dt) == 14:
220
        date_format = "%Y%m%d%H%M%S"
221
    else:
222
        raise ValueError("No ANSI format date")
223
    return datetime.strptime(dt, date_format)
224
225
226
def to_ansi(dt, show_time=True, timezone=None):
227
    """Returns the date in ANSI X3.30/X4.43.3) format
228
    :param dt: DateTime/datetime/date
229
    :param show_time: if true, returns YYYYMMDDHHMMSS. YYYYMMDD otherwise
230
    :param timezone: if set, converts the date to the given timezone
231
    :returns: str that represents the datetime in ANSI format
232
    """
233
    dt = to_dt(dt)
234
    if dt is None:
235
        return None
236
237
    if timezone and is_valid_timezone(timezone):
238
        if is_timezone_naive(dt):
239
            # XXX Localize to default TZ to overcome `to_dt` inconsistency:
240
            #
241
            # - if the input value is a datetime/date type, `to_dt` does not
242
            #   convert to the OS's zone, even if the value is tz-naive.
243
            # - if the input value is a str or DateTime, `to_dt` does  convert
244
            #   to the system default zone.
245
            #
246
            # For instance:
247
            # >>> to_dt("19891201131405")
248
            # datetime.datetime(1989, 12, 1, 13, 14, 5, tzinfo=<StaticTzInfo 'Etc/GMT'>)
249
            # >>> ansi_to_dt("19891201131405")
250
            # datetime.datetime(1989, 12, 1, 13, 14, 5)
251
            #
252
            # That causes the following inconsistency:
253
            # >>> to_ansi(to_dt("19891201131405"), timezone="Pacific/Fiji")
254
            # 19891202011405
255
            # >>> to_ansi(ansi_to_dt("19891201131405"), timezone="Pacific/Fiji")
256
            # 19891201131405
257
258
            default_tz = get_timezone(dt)
259
            default_zone = pytz.timezone(default_tz)
260
            dt = default_zone.localize(dt)
261
262
        dt = to_zone(dt, timezone)
263
264
    ansi = "{:04d}{:02d}{:02d}".format(dt.year, dt.month, dt.day)
265
    if not show_time:
266
        return ansi
267
    return "{}{:02d}{:02d}{:02d}".format(ansi, dt.hour, dt.minute, dt.second)
268
269
270
def get_timezone(dt, default="Etc/GMT"):
271
    """Get a valid pytz timezone name of the datetime object
272
273
    :param dt: date object
274
    :returns: timezone as string, e.g. Etc/GMT or CET
275
    """
276
    tz = None
277
    if is_dt(dt):
278
        tz = dt.tzname()
279
    elif is_DT(dt):
280
        tz = dt.timezone()
281
    elif is_d(dt):
282
        tz = default
283
284
    if tz:
285
        # convert DateTime `GMT` to `Etc/GMT` timezones
286
        # NOTE: `GMT+1` get `Etc/GMT-1`!
287
        #
288
        # The special area of "Etc" is used for some administrative zones,
289
        # particularly for "Etc/UTC" which represents Coordinated Universal
290
        # Time. In order to conform with the POSIX style, those zone names
291
        # beginning with "Etc/GMT" have their sign reversed from the standard
292
        # ISO 8601 convention. In the "Etc" area, zones west of GMT have a
293
        # positive sign and those east have a negative sign in their name (e.g
294
        # "Etc/GMT-14" is 14 hours ahead of GMT).
295
        # --- From https://en.wikipedia.org/wiki/Tz_database#Area
296
        match = re.match(RX_GMT, tz)
297
        if match:
298
            groups = match.groups()
299
            hours = to_int(groups[2], 0)
300
            if not hours:
301
                return "Etc/GMT"
302
303
            offset = "-" if groups[1] == "+" else "+"
304
            tz = "Etc/GMT%s%s" % (offset, hours)
305
    else:
306
        tz = default
307
308
    return tz
309
310
311
def get_tzinfo(dt_tz, default=pytz.UTC):
312
    """Returns the valid pytz tinfo from the date or timezone name
313
314
    Returns the default timezone info if date does not have a valid timezone
315
    set or is TZ-naive
316
317
    :param dt: timezone name or date object to extract the tzinfo
318
    :type dt: str/date/datetime/DateTime
319
    :param: default: timezone name or pytz tzinfo object
320
    :returns: pytz tzinfo object, e.g. `<UTC>, <StaticTzInfo 'Etc/GMT+2'>
321
    :rtype: UTC/BaseTzInfo/StaticTzInfo/DstTzInfo
322
    """
323
    if is_str(default):
324
        default = pytz.timezone(default)
325
    try:
326
        if is_str(dt_tz):
327
            return pytz.timezone(dt_tz)
328
        tz = get_timezone(dt_tz, default=default.zone)
329
        return pytz.timezone(tz)
330
    except pytz.UnknownTimeZoneError:
331
        return default
332
333
334
def is_valid_timezone(timezone):
335
    """Checks if the timezone is a valid pytz/Olson name
336
337
    :param timezone: pytz/Olson timezone name
338
    :returns: True when the timezone is a valid zone
339
    """
340
    try:
341
        pytz.timezone(timezone)
342
        return True
343
    except pytz.UnknownTimeZoneError:
344
        return False
345
346
347
def get_os_timezone(default="Etc/GMT"):
348
    """Return the default timezone of the system
349
350
    :returns: OS timezone or default timezone
351
    """
352
    timezone = None
353
    if "TZ" in os.environ.keys():
354
        # Timezone from OS env var
355
        timezone = os.environ["TZ"]
356
    if not timezone:
357
        # Timezone from python time
358
        zones = time.tzname
359
        if zones and len(zones) > 0:
360
            timezone = zones[0]
361
        else:
362
            logger.warn(
363
                "Operating system\'s timezone cannot be found. "
364
                "Falling back to %s." % default)
365
            timezone = default
366
    if not is_valid_timezone(timezone):
367
        return default
368
    return timezone
369
370
371
def to_zone(dt, timezone):
372
    """Convert date to timezone
373
374
    Adds the timezone for timezone naive datetimes
375
376
    :param dt: date object
377
    :param timezone: timezone
378
    :returns: date converted to timezone
379
    """
380
    if is_dt(dt) or is_d(dt):
381
        dt = to_dt(dt)
382
        zone = pytz.timezone(timezone)
383
        if is_timezone_aware(dt):
384
            return dt.astimezone(zone)
385
        return zone.localize(dt)
386
    elif is_DT(dt):
387
        # NOTE: This shifts the time according to the TZ offset
388
        return dt.toZone(timezone)
389
    raise TypeError("Expected a date, got '%r'" % type(dt))
390
391
392
def to_timestamp(dt):
393
    """Generate a Portable Operating System Interface (POSIX) timestamp
394
395
    :param dt: date object
396
    :returns: timestamp in seconds
397
    """
398
    timestamp = 0
399
    if is_DT(dt):
400
        timestamp = dt.timeTime()
401
    elif is_dt(dt):
402
        timestamp = time.mktime(dt.timetuple())
403
    elif is_str(dt):
404
        DT = to_DT(dt)
405
        return to_timestamp(DT)
406
    return timestamp
407
408
409
def from_timestamp(timestamp):
410
    """Generate a datetime object from a POSIX timestamp
411
412
    :param timestamp: POSIX timestamp
413
    :returns: datetime object
414
    """
415
    return datetime.utcfromtimestamp(timestamp)
416
417
418
def to_iso_format(dt):
419
    """Convert to ISO format
420
    """
421
    if is_dt(dt):
422
        return dt.isoformat()
423
    elif is_DT(dt):
424
        return dt.ISO()
425
    elif is_str(dt):
426
        DT = to_DT(dt)
427
        return to_iso_format(DT)
428
    return None
429
430
431
def date_to_string(dt, fmt="%Y-%m-%d", default=""):
432
    """Format the date to string
433
    """
434
    if not is_date(dt):
435
        return default
436
437
    # NOTE: The function `is_date` evaluates also string dates as `True`.
438
    #       We ensure in such a case to have a `DateTime` object and leave
439
    #       possible `datetime` objects unchanged.
440
    if isinstance(dt, six.string_types):
441
        dt = to_DT(dt)
442
443
    try:
444
        return dt.strftime(fmt)
445
    except ValueError:
446
        #  Fix ValueError: year=1111 is before 1900;
447
        #  the datetime strftime() methods require year >= 1900
448
449
        # convert format string to be something like "${Y}-${m}-${d}"
450
        new_fmt = ""
451
        var = False
452
        for x in fmt:
453
            if x == "%":
454
                var = True
455
                new_fmt += "${"
456
                continue
457
            if var:
458
                new_fmt += x
459
                new_fmt += "}"
460
                var = False
461
            else:
462
                new_fmt += x
463
464
        def pad(val):
465
            """Add a zero if val is a single digit
466
            """
467
            return "{:0>2}".format(val)
468
469
        # Manually extract relevant date and time parts
470
        dt = to_DT(dt)
471
        data = {
472
            "Y": dt.year(),
473
            "y": dt.yy(),
474
            "m": dt.mm(),
475
            "d": dt.dd(),
476
            "H": pad(dt.h_24()),
477
            "I": pad(dt.h_12()),
478
            "M": pad(dt.minute()),
479
            "p": dt.ampm().upper(),
480
            "S": dt.second(),
481
        }
482
483
        return Template(new_fmt).safe_substitute(data)
484
485
486
def to_localized_time(dt, long_format=None, time_only=None,
487
                      context=None, request=None, default=""):
488
    """Convert a date object to a localized string
489
490
    :param dt: The date/time to localize
491
    :type dt: str/datetime/DateTime
492
    :param long_format: Return long date/time if True
493
    :type portal_type: boolean/null
494
    :param time_only: If True, only returns time.
495
    :type title: boolean/null
496
    :param context: The current context
497
    :type context: ATContentType
498
    :param request: The current request
499
    :type request: HTTPRequest object
500
    :returns: The formatted date as string
501
    :rtype: string
502
    """
503
    if not dt:
504
        return default
505
506
    try:
507
        ts = get_tool("translation_service")
508
        time_str = ts.ulocalized_time(
509
            dt, long_format, time_only, context, "senaite.core", request)
510
    except ValueError:
511
        # Handle dates < 1900
512
513
        # code taken from Products.CMFPlone.i18nl110n.ulocalized_time
514
        if time_only:
515
            msgid = "time_format"
516
        elif long_format:
517
            msgid = "date_format_long"
518
        else:
519
            msgid = "date_format_short"
520
521
        formatstring = translate(msgid, "senaite.core", {}, request)
522
        if formatstring == msgid:
523
            if msgid == "date_format_long":
524
                formatstring = "%Y-%m-%d %H:%M"  # 2038-01-19 03:14
525
            elif msgid == "date_format_short":
526
                formatstring = "%Y-%m-%d"  # 2038-01-19
527
            elif msgid == "time_format":
528
                formatstring = "%H:%M"  # 03:14
529
            else:
530
                formatstring = "[INTERNAL ERROR]"
531
        time_str = date_to_string(dt, formatstring, default=default)
532
    return time_str
533
534
535
def get_relative_delta(dt1, dt2=None):
536
    """Calculates the relative delta between two dates or datetimes
537
538
    If `dt2` is None, the current datetime is used.
539
540
    :param dt1: the first date/time to compare
541
    :type dt1: string/date/datetime/DateTime
542
    :param dt2: the second date/time to compare
543
    :type dt2: string/date/datetime/DateTime
544
    :returns: interval of time (e.g. `relativedelta(hours=+3)`)
545
    :rtype: dateutil.relativedelta
546
    """
547
    if not dt2:
548
        dt2 = datetime.now()
549
550
    dt1 = to_dt(dt1)
551
    dt2 = to_dt(dt2)
552
    if not all([dt1, dt2]):
553
        raise ValueError("No valid date or dates")
554
555
    naives = [is_timezone_naive(dt) for dt in [dt1, dt2]]
556
    if all(naives):
557
        # Both naive, no need to do anything special
558
        return relativedelta(dt2, dt1)
559
560
    elif is_timezone_naive(dt1):
561
        # From date is naive, assume same TZ as the to date
562
        tzinfo = get_tzinfo(dt2)
563
        dt1 = dt1.replace(tzinfo=tzinfo)
564
565
    elif is_timezone_naive(dt2):
566
        # To date is naive, assume same TZ as the from date
567
        tzinfo = get_tzinfo(dt1)
568
        dt2 = dt2.replace(tzinfo=tzinfo)
569
570
    return relativedelta(dt2, dt1)
571
572
573
def timedelta_to_dict(value, default=_marker):
574
    """Converts timedelta value to dict object
575
576
    {
577
        "days": 10,
578
        "hours": 10,
579
        "minutes": 10,
580
        "seconds": 10,
581
    }
582
583
    :param value: timedelta object for conversion
584
    :type value: timedelta
585
    :param value: timedelta object for conversion
586
    :type value: timedelta
587
    :returns converted timedelta as dict or default object
588
    :rtype: dict or default object
589
    """
590
591
    if not isinstance(value, timedelta):
592
        if default is _marker:
593
            raise TypeError("%r is not supported" % type(value))
594
        logger.warn(
595
            "Invalid value passed to timedelta->dict conversion. "
596
            "Falling back to default: %s." % default)
597
        return default
598
599
    # Note timedelta keeps days and seconds a part!
600
    return {
601
        "days": value.days,
602
        "hours": value.seconds // 3600,  # hours within a day
603
        "minutes": (value.seconds % 3600) // 60,  # minutes within an hour
604
        "seconds": value.seconds % 60,  # seconds within a minute
605
    }
606
607
608
def to_timedelta(value, default=_marker):
609
    """Converts dict object w/ days, hours, minutes, seconds keys to
610
       timedelta format
611
612
    :param value: dict object for conversion
613
    :type value: dict
614
    :returns converted timedelta
615
    :rtype: timedelta
616
    """
617
618
    if isinstance(value, timedelta):
619
        return value
620
621
    if not isinstance(value, dict):
622
        if default is _marker:
623
            raise TypeError("%r is not supported" % type(value))
624
        logger.warn(
625
            "Invalid value passed to dict->timedelta conversion. "
626
            "Falling back to default: %s." % default)
627
        return default
628
629
    return timedelta(
630
        days=to_int(value.get('days', 0), 0),
631
        hours=to_int(value.get('hours', 0), 0),
632
        minutes=to_int(value.get('minutes', 0), 0),
633
        seconds=to_int(value.get('seconds', 0), 0)
634
    )
635